@expo/expo-modules-macros-plugin 0.8.0 → 0.10.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.
- package/.github/workflows/swift.yml +3 -0
- package/apple/ExpoModulesMacros-tool +0 -0
- package/apple/Package.swift +7 -10
- package/apple/Sources/ExpoModulesMacros/DecorateModuleBuilder.swift +3 -4
- package/apple/Sources/ExpoModulesMacros/ExpoModuleMacro.swift +7 -0
- package/apple/Sources/ExpoModulesMacros/FreeFormTypes.swift +59 -0
- package/apple/Sources/ExpoModulesMacros/JSConstructor.swift +2 -2
- package/apple/Sources/ExpoModulesMacros/JSMacro.swift +188 -0
- package/apple/Sources/ExpoModulesMacros/MacroHelpers.swift +77 -0
- package/apple/Sources/ExpoModulesMacros/Plugin.swift +19 -1
- package/apple/Sources/ExpoModulesMacros/RecordMacro.swift +0 -31
- package/apple/Sources/ExpoModulesMacros/SharedObjectMacro.swift +8 -2
- package/apple/Sources/ExpoModulesMacros/TypeConformanceAssertion.swift +17 -9
- package/apple/Sources/ExpoModulesMacros/UnionMacro.swift +365 -0
- package/apple/Sources/ExpoModulesScanner/CLI.swift +110 -0
- package/apple/Sources/ExpoModulesScanner/Core/Detection.swift +16 -0
- package/apple/Sources/ExpoModulesScanner/Core/DetectionVisitor.swift +60 -10
- package/apple/Sources/ExpoModulesScanner/Core/ScanBuildConfiguration.swift +121 -0
- package/apple/Sources/ExpoModulesScanner/Core/SourceScan.swift +38 -14
- package/apple/Sources/ExpoModulesScanner/Modules/ScanModules.swift +32 -6
- package/package.json +1 -1
- package/apple/Sources/ExpoModulesScannerCLI/main.swift +0 -71
|
@@ -54,6 +54,9 @@ jobs:
|
|
|
54
54
|
case "$archs" in *x86_64*) ;; *) echo "Missing x86_64 slice"; exit 1;; esac
|
|
55
55
|
arch -arm64 ./ExpoModulesMacros-tool < /dev/null
|
|
56
56
|
arch -x86_64 ./ExpoModulesMacros-tool < /dev/null
|
|
57
|
+
# With arguments the same binary runs the scanner CLI instead of the plugin server.
|
|
58
|
+
arch -arm64 ./ExpoModulesMacros-tool --help > /dev/null
|
|
59
|
+
arch -x86_64 ./ExpoModulesMacros-tool --help > /dev/null
|
|
57
60
|
|
|
58
61
|
- name: Test
|
|
59
62
|
run: swift test -v
|
|
Binary file
|
package/apple/Package.swift
CHANGED
|
@@ -8,19 +8,19 @@ import PackageDescription
|
|
|
8
8
|
let package = Package(
|
|
9
9
|
name: "ExpoModulesMacros",
|
|
10
10
|
platforms: [.macOS(.v13)],
|
|
11
|
-
products: [
|
|
12
|
-
// The scanner CLI. Named `ExpoModulesScanner` (the user-facing tool name) while its target is
|
|
13
|
-
// `ExpoModulesScannerCLI`; the detection logic lives in the importable `ExpoModulesScanner`
|
|
14
|
-
// library that both the CLI and the tests depend on.
|
|
15
|
-
.executable(name: "ExpoModulesScanner", targets: ["ExpoModulesScannerCLI"]),
|
|
16
|
-
],
|
|
17
11
|
dependencies: [
|
|
18
12
|
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0-latest")
|
|
19
13
|
],
|
|
20
14
|
targets: [
|
|
15
|
+
// The plugin executable doubles as the scanner CLI: the compiler launches it without arguments
|
|
16
|
+
// and speaks the plugin protocol over stdin, while an invocation with arguments dispatches into
|
|
17
|
+
// `ScannerCLI` (see the entry point in Plugin.swift). Sharing the binary keeps the package to a
|
|
18
|
+
// single shipped executable; the scanner adds little on top of the SwiftSyntax the macros
|
|
19
|
+
// already link.
|
|
21
20
|
.macro(
|
|
22
21
|
name: "ExpoModulesMacros",
|
|
23
22
|
dependencies: [
|
|
23
|
+
"ExpoModulesScanner",
|
|
24
24
|
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
|
|
25
25
|
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
|
|
26
26
|
]
|
|
@@ -30,12 +30,9 @@ let package = Package(
|
|
|
30
30
|
dependencies: [
|
|
31
31
|
.product(name: "SwiftSyntax", package: "swift-syntax"),
|
|
32
32
|
.product(name: "SwiftParser", package: "swift-syntax"),
|
|
33
|
+
.product(name: "SwiftIfConfig", package: "swift-syntax"),
|
|
33
34
|
]
|
|
34
35
|
),
|
|
35
|
-
.executableTarget(
|
|
36
|
-
name: "ExpoModulesScannerCLI",
|
|
37
|
-
dependencies: ["ExpoModulesScanner"]
|
|
38
|
-
),
|
|
39
36
|
]
|
|
40
37
|
)
|
|
41
38
|
|
|
@@ -166,8 +166,8 @@ internal struct JSFunction {
|
|
|
166
166
|
/// primitive's `decode` is `@inlinable` and lowers to the same direct accessor a hand-rolled fast
|
|
167
167
|
/// path would use.
|
|
168
168
|
private func decodeStatement(at index: Int) -> String {
|
|
169
|
-
let
|
|
170
|
-
return "let arg\(index) = try \(
|
|
169
|
+
let type = parameters[index].type.trimmedDescription
|
|
170
|
+
return "let arg\(index) = try \(decodeCall(type, from: "arguments.unownedValue(at: \(index))"))"
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
/// The `<callee>.<name>(...)` call for the given arity. Slots `0..<arity` are passed their decoded
|
|
@@ -332,8 +332,7 @@ internal struct JSProperty {
|
|
|
332
332
|
// getter-only (a settable var with neither an annotation nor a literal default is rare and can't
|
|
333
333
|
// be decoded).
|
|
334
334
|
if isSettable, let valueType {
|
|
335
|
-
let
|
|
336
|
-
let setDecode = "\(callee).\(swiftName) = try \(exprType).decode(arguments.unownedValue(at: 0), in: runtime)"
|
|
335
|
+
let setDecode = "\(callee).\(swiftName) = try \(decodeCall(valueType, from: "arguments.unownedValue(at: 0)"))"
|
|
337
336
|
lines.append(
|
|
338
337
|
accessorClosure(
|
|
339
338
|
descriptorName, "set", receiver: receiver, body: "\(unwrap)\(setDecode)\nreturn .undefined"))
|
|
@@ -151,6 +151,13 @@ extension ExpoModuleMacro: MemberAttributeMacro {
|
|
|
151
151
|
attributes.append("@JavaScriptActor")
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
// `@JS(.concurrent)` is the inverse: instead of the JS-thread stamp the member gets
|
|
155
|
+
// `@concurrent`, so its body runs on the concurrent pool. `shouldStampJavaScriptActor` already
|
|
156
|
+
// skipped the stamp above, leaving the two mutually exclusive.
|
|
157
|
+
if isConcurrentJSMember(member) {
|
|
158
|
+
attributes.append("@concurrent")
|
|
159
|
+
}
|
|
160
|
+
|
|
154
161
|
// Apply the result builder to `definition()` so the user doesn't have to. Skipped if
|
|
155
162
|
// they already wrote `@ModuleDefinitionBuilder` themselves, which would otherwise be
|
|
156
163
|
// a duplicate attribute.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/// Recognition of the free-form (`Any`-bearing) boundary types and the spellings the macros emit or
|
|
2
|
+
/// suggest for them. None of these types can conform to the JS codable protocols (`Any` can't conform
|
|
3
|
+
/// to a protocol, and the container conditional conformances require the element/value to conform), so
|
|
4
|
+
/// they can't cross the boundary through the usual `T.decode` / `T.encode` path. Instead they're
|
|
5
|
+
/// accepted **only** in decode position (function/constructor arguments), decoded through a dedicated
|
|
6
|
+
/// `JavaScriptValue.decodeAny…` entry point; a free-form return or getter is rejected, since there's
|
|
7
|
+
/// no free-form encode.
|
|
8
|
+
///
|
|
9
|
+
/// Shared across the macro pipeline: `TypeConformanceAssertion` skips them, `MacroHelpers.decodeCall`
|
|
10
|
+
/// reroutes their decode, and `JSMacro` uses them for the steering diagnostics.
|
|
11
|
+
|
|
12
|
+
/// The free-form boundary types: an untyped value (`Any`), an untyped array (`[Any]`), and an untyped
|
|
13
|
+
/// string-keyed dictionary (`[String: Any]`). Matched by whitespace-normalized spelling so
|
|
14
|
+
/// `[String: Any]` and `[String : Any]` both count.
|
|
15
|
+
private let freeFormBoundaryTypes: Set<String> = ["Any", "[Any]", "[String:Any]"]
|
|
16
|
+
|
|
17
|
+
/// The `JavaScriptValue.decodeAny…` method the binding calls to decode a free-form argument, keyed by
|
|
18
|
+
/// the free-form type's normalized spelling. `nil` for any non-free-form type.
|
|
19
|
+
private let freeFormDecodeMethods: [String: String] = [
|
|
20
|
+
"Any": "decodeAny",
|
|
21
|
+
"[Any]": "decodeAnyArray",
|
|
22
|
+
"[String:Any]": "decodeAnyDictionary",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/// The type-safe alternative a free-form type's diagnostic steers to: the same shape with the `Any`
|
|
26
|
+
/// element replaced by `JavaScriptValue`, which conforms to the codable protocols. Keyed by the
|
|
27
|
+
/// free-form type's normalized spelling; spelled with conventional spacing for the message.
|
|
28
|
+
private let typedFreeFormReplacements: [String: String] = [
|
|
29
|
+
"Any": "JavaScriptValue",
|
|
30
|
+
"[Any]": "[JavaScriptValue]",
|
|
31
|
+
"[String:Any]": "[String: JavaScriptValue]",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
/// True when a boundary type (as written) is one of the free-form spellings, ignoring internal
|
|
35
|
+
/// whitespace so `[String: Any]` and `[String :Any]` both match. Optionals are not free-form here: a
|
|
36
|
+
/// trailing `?` would route through `Optional.decode`, which free-form can't satisfy, so an optional
|
|
37
|
+
/// free-form type isn't recognized and stays a normal (failing) assertion.
|
|
38
|
+
internal func isFreeFormBoundaryType(_ type: String) -> Bool {
|
|
39
|
+
return freeFormBoundaryTypes.contains(normalizedTypeSpelling(type))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// The `JavaScriptValue.decodeAny…` method name for a free-form boundary type, or `nil` when the type
|
|
43
|
+
/// isn't free-form. The binding emits `JavaScriptValue.<method>(arguments.unownedValue(at:), in:)` in
|
|
44
|
+
/// place of the type's own `.decode`.
|
|
45
|
+
internal func freeFormDecodeMethod(for type: String) -> String? {
|
|
46
|
+
return freeFormDecodeMethods[normalizedTypeSpelling(type)]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// The type-safe alternative to suggest in place of a free-form type (its `Any` element replaced by
|
|
50
|
+
/// `JavaScriptValue`), or `nil` when the type isn't free-form.
|
|
51
|
+
internal func typedFreeFormReplacement(for type: String) -> String? {
|
|
52
|
+
return typedFreeFormReplacements[normalizedTypeSpelling(type)]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// A type spelling with all whitespace removed, so spelling variations of the same type
|
|
56
|
+
/// (`[String: Any]` vs `[String :Any]`) compare equal.
|
|
57
|
+
private func normalizedTypeSpelling(_ type: String) -> String {
|
|
58
|
+
return type.filter { !$0.isWhitespace }
|
|
59
|
+
}
|
|
@@ -27,8 +27,8 @@ internal struct JSConstructor {
|
|
|
27
27
|
|
|
28
28
|
var callArguments: [String] = []
|
|
29
29
|
for (index, parameter) in parameters.enumerated() {
|
|
30
|
-
let
|
|
31
|
-
lines.append("let arg\(index) = try \(
|
|
30
|
+
let type = parameter.type.trimmedDescription
|
|
31
|
+
lines.append("let arg\(index) = try \(decodeCall(type, from: "arguments.unownedValue(at: \(index))"))")
|
|
32
32
|
|
|
33
33
|
let label = parameter.firstName.text
|
|
34
34
|
callArguments.append(label == "_" ? "arg\(index)" : "\(label): arg\(index)")
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import SwiftDiagnostics
|
|
1
2
|
import SwiftSyntax
|
|
2
3
|
import SwiftSyntaxMacros
|
|
3
4
|
|
|
@@ -30,6 +31,9 @@ public struct JSMacro: PeerMacro {
|
|
|
30
31
|
providingPeersOf declaration: some DeclSyntaxProtocol,
|
|
31
32
|
in context: some MacroExpansionContext
|
|
32
33
|
) throws -> [DeclSyntax] {
|
|
34
|
+
diagnoseFreeFormTypes(in: declaration, in: context)
|
|
35
|
+
diagnoseConcurrentOption(of: node, on: declaration, in: context)
|
|
36
|
+
|
|
33
37
|
guard let member = boundaryMember(of: declaration),
|
|
34
38
|
let assertion = directionalConformanceAssertion(
|
|
35
39
|
name: member.name,
|
|
@@ -43,6 +47,190 @@ public struct JSMacro: PeerMacro {
|
|
|
43
47
|
}
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
/// Emits the diagnostic for `@JS(.concurrent)` on a member that can't take it. The option maps to
|
|
51
|
+
/// Swift's `@concurrent`, which requires an `async` function: a synchronous member has nowhere to
|
|
52
|
+
/// suspend, and a property or initializer can't be async at all. Diagnosing here points at the
|
|
53
|
+
/// user's own `@JS` attribute rather than at the `@concurrent` the module macro would attach.
|
|
54
|
+
private func diagnoseConcurrentOption(
|
|
55
|
+
of node: AttributeSyntax,
|
|
56
|
+
on declaration: some DeclSyntaxProtocol,
|
|
57
|
+
in context: some MacroExpansionContext
|
|
58
|
+
) {
|
|
59
|
+
guard hasJSOption(node, named: "concurrent") else {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
|
|
63
|
+
guard funcDecl.signature.effectSpecifiers?.asyncSpecifier == nil else {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
context.diagnose(
|
|
67
|
+
Diagnostic(
|
|
68
|
+
node: node,
|
|
69
|
+
message: JSDiagnosticMessage(
|
|
70
|
+
"'.concurrent' needs an 'async' function: a synchronous @JS member runs on the JavaScript thread by definition. Mark the function 'async' to run its body off that thread.",
|
|
71
|
+
id: "js-concurrent-requires-async",
|
|
72
|
+
severity: .error
|
|
73
|
+
),
|
|
74
|
+
fixIts: [insertAsyncFixIt(for: funcDecl)]))
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
context.diagnose(
|
|
78
|
+
Diagnostic(
|
|
79
|
+
node: node,
|
|
80
|
+
message: JSDiagnosticMessage(
|
|
81
|
+
"'.concurrent' applies only to an 'async' @JS function, not to a property or initializer.",
|
|
82
|
+
id: "js-concurrent-requires-function",
|
|
83
|
+
severity: .error
|
|
84
|
+
)))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// The fix-it offered alongside the synchronous-function diagnostic: insert `async` into the
|
|
88
|
+
/// signature so `@JS(.concurrent)` becomes valid. The macro can't add the keyword itself (no macro
|
|
89
|
+
/// role rewrites the declaration it's attached to), but Xcode can apply this in one click.
|
|
90
|
+
///
|
|
91
|
+
/// `async` goes at the front of the effect specifiers, ahead of any `throws`, which is the only
|
|
92
|
+
/// order Swift accepts. When the signature has no effect specifiers yet, the new clause inherits
|
|
93
|
+
/// what the parameter clause had trailing it and the parameter clause is left with a single space,
|
|
94
|
+
/// so `() -> Int` becomes `() async -> Int` rather than `() async-> Int`.
|
|
95
|
+
private func insertAsyncFixIt(for funcDecl: FunctionDeclSyntax) -> FixIt {
|
|
96
|
+
let signature = funcDecl.signature
|
|
97
|
+
var newSignature = signature
|
|
98
|
+
|
|
99
|
+
if var effectSpecifiers = signature.effectSpecifiers {
|
|
100
|
+
effectSpecifiers.asyncSpecifier = .keyword(.async, trailingTrivia: .space)
|
|
101
|
+
newSignature.effectSpecifiers = effectSpecifiers
|
|
102
|
+
} else {
|
|
103
|
+
newSignature.effectSpecifiers = FunctionEffectSpecifiersSyntax(
|
|
104
|
+
asyncSpecifier: .keyword(.async, trailingTrivia: signature.parameterClause.trailingTrivia)
|
|
105
|
+
)
|
|
106
|
+
newSignature.parameterClause.trailingTrivia = .space
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return FixIt(
|
|
110
|
+
message: JSFixItMessage("Mark the function 'async'", id: "js-concurrent-insert-async"),
|
|
111
|
+
changes: [.replace(oldNode: Syntax(signature), newNode: Syntax(newSignature))]
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// Emits the free-form (`Any` / `[Any]` / `[String: Any]`) diagnostics for a `@JS` declaration.
|
|
116
|
+
///
|
|
117
|
+
/// A free-form type is only supported crossing the boundary as an **argument**, decoded through
|
|
118
|
+
/// `JavaScriptValue.decodeAny…`; there is no free-form encode, so any position that encodes is a hard
|
|
119
|
+
/// error. That means:
|
|
120
|
+
/// - a function/constructor **parameter** typed free-form gets a warning steering to
|
|
121
|
+
/// `[String: JavaScriptValue]` (the type-safe alternative), but compiles;
|
|
122
|
+
/// - a function **return** typed free-form is an error (it would need to encode);
|
|
123
|
+
/// - a **property** typed free-form is an error regardless of settability, because its getter always
|
|
124
|
+
/// encodes.
|
|
125
|
+
///
|
|
126
|
+
/// Types are matched by their written spelling on the type node, so the diagnostic points at the
|
|
127
|
+
/// offending type in the user's source.
|
|
128
|
+
private func diagnoseFreeFormTypes(
|
|
129
|
+
in declaration: some DeclSyntaxProtocol,
|
|
130
|
+
in context: some MacroExpansionContext
|
|
131
|
+
) {
|
|
132
|
+
if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
|
|
133
|
+
warnFreeFormArguments(funcDecl.signature.parameterClause.parameters, in: context)
|
|
134
|
+
if let returnType = funcDecl.signature.returnClause?.type,
|
|
135
|
+
isFreeFormBoundaryType(returnType.trimmedDescription) {
|
|
136
|
+
context.diagnose(
|
|
137
|
+
Diagnostic(node: returnType, message: freeFormReturnError(for: returnType.trimmedDescription)))
|
|
138
|
+
}
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A constructor decodes its arguments exactly like a function; it has no return value to encode, so
|
|
143
|
+
// only the argument warning applies.
|
|
144
|
+
if let initDecl = declaration.as(InitializerDeclSyntax.self) {
|
|
145
|
+
warnFreeFormArguments(initDecl.signature.parameterClause.parameters, in: context)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if let varDecl = declaration.as(VariableDeclSyntax.self),
|
|
150
|
+
let type = varDecl.bindings.first?.typeAnnotation?.type,
|
|
151
|
+
isFreeFormBoundaryType(type.trimmedDescription) {
|
|
152
|
+
context.diagnose(
|
|
153
|
+
Diagnostic(node: type, message: freeFormPropertyError(for: type.trimmedDescription)))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// The tail of a free-form encode error: the reshaped `JavaScriptValue` alternative when the type is a
|
|
158
|
+
/// container (`[String: Any]` -> `[String: JavaScriptValue]`), otherwise the passthrough suggestion
|
|
159
|
+
/// for a bare `Any`. Both conform to the codable protocols, so either is a valid fix.
|
|
160
|
+
private func suggestedAlternative(for freeFormType: String) -> String {
|
|
161
|
+
if let reshaped = typedFreeFormReplacement(for: freeFormType), reshaped != "JavaScriptValue" {
|
|
162
|
+
return "Use '\(reshaped)', or 'JavaScriptValue' to pass a JS value through unchanged."
|
|
163
|
+
}
|
|
164
|
+
return "Use a concrete type, or 'JavaScriptValue' to pass a JS value through unchanged."
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// Emits the steering warning for each free-form parameter in a list. Shared by the function and
|
|
168
|
+
/// constructor cases, which both decode their arguments through the same path.
|
|
169
|
+
private func warnFreeFormArguments(
|
|
170
|
+
_ parameters: FunctionParameterListSyntax,
|
|
171
|
+
in context: some MacroExpansionContext
|
|
172
|
+
) {
|
|
173
|
+
for parameter in parameters {
|
|
174
|
+
let type = parameter.type.trimmedDescription
|
|
175
|
+
guard let suggested = typedFreeFormReplacement(for: type) else {
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
context.diagnose(
|
|
179
|
+
Diagnostic(
|
|
180
|
+
node: parameter.type,
|
|
181
|
+
message: freeFormArgumentWarning(for: type, suggesting: suggested)))
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private func freeFormArgumentWarning(
|
|
186
|
+
for freeFormType: String,
|
|
187
|
+
suggesting suggestedType: String
|
|
188
|
+
) -> JSDiagnosticMessage {
|
|
189
|
+
return JSDiagnosticMessage(
|
|
190
|
+
"Prefer '\(suggestedType)' over the free-form '\(freeFormType)' for a @JS argument. Free-form decoding boxes every value as 'Any' (slower, no static typing); the 'JavaScriptValue' element keeps each value inspectable without erasing it.",
|
|
191
|
+
id: "js-free-form-argument",
|
|
192
|
+
severity: .warning
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private func freeFormReturnError(for freeFormType: String) -> JSDiagnosticMessage {
|
|
197
|
+
return JSDiagnosticMessage(
|
|
198
|
+
"A @JS function can't return the free-form '\(freeFormType)': there's no way to encode an untyped value back to JavaScript. \(suggestedAlternative(for: freeFormType))",
|
|
199
|
+
id: "js-free-form-return",
|
|
200
|
+
severity: .error
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private func freeFormPropertyError(for freeFormType: String) -> JSDiagnosticMessage {
|
|
205
|
+
return JSDiagnosticMessage(
|
|
206
|
+
"A @JS property can't have the free-form '\(freeFormType)': its getter would have to encode an untyped value back to JavaScript, which isn't supported. \(suggestedAlternative(for: freeFormType))",
|
|
207
|
+
id: "js-free-form-property",
|
|
208
|
+
severity: .error
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private struct JSDiagnosticMessage: DiagnosticMessage {
|
|
213
|
+
let message: String
|
|
214
|
+
let diagnosticID: MessageID
|
|
215
|
+
let severity: DiagnosticSeverity
|
|
216
|
+
|
|
217
|
+
init(_ message: String, id: String, severity: DiagnosticSeverity) {
|
|
218
|
+
self.message = message
|
|
219
|
+
self.diagnosticID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
220
|
+
self.severity = severity
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private struct JSFixItMessage: FixItMessage {
|
|
225
|
+
let message: String
|
|
226
|
+
let fixItID: MessageID
|
|
227
|
+
|
|
228
|
+
init(_ message: String, id: String) {
|
|
229
|
+
self.message = message
|
|
230
|
+
self.fixItID = MessageID(domain: "ExpoModulesMacros", id: id)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
46
234
|
/// What an assertion peer needs about the `@JS` member it sits beside: a name (to keep the peer unique
|
|
47
235
|
/// among siblings), the boundary types split by conversion direction, and whether the member is
|
|
48
236
|
/// type-level. Arguments (and a settable property's incoming value) are decoded; return values (and a
|
|
@@ -31,6 +31,44 @@ internal func boolArgument(of attribute: AttributeSyntax, label: String) -> Bool
|
|
|
31
31
|
return nil
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/// True if the attribute lists the given `JSOptions` member, e.g. `@JS(.concurrent)` or
|
|
35
|
+
/// `@JS("name", [.concurrent])` -> true for "concurrent". Options are written as member-access
|
|
36
|
+
/// expressions (`.concurrent`), optionally inside an array literal when more than one is combined, so
|
|
37
|
+
/// both spellings are scanned. The leading string literal, when present, is the JS name and is skipped.
|
|
38
|
+
internal func hasJSOption(_ attribute: AttributeSyntax, named option: String) -> Bool {
|
|
39
|
+
guard let args = attribute.arguments?.as(LabeledExprListSyntax.self) else {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
for arg in args {
|
|
43
|
+
if namesOption(arg.expression, option) {
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
if let array = arg.expression.as(ArrayExprSyntax.self),
|
|
47
|
+
array.elements.contains(where: { namesOption($0.expression, option) }) {
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// True when the expression is the member access `.<option>` (or `JSOptions.<option>`).
|
|
55
|
+
private func namesOption(_ expression: ExprSyntax, _ option: String) -> Bool {
|
|
56
|
+
guard let member = expression.as(MemberAccessExprSyntax.self) else {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
return member.declName.baseName.text == option
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// True if the `@JS`-marked declaration opted into running off the JavaScript thread with
|
|
63
|
+
/// `@JS(.concurrent)`. Such a member is left unstamped and gets `@concurrent` instead, so its body
|
|
64
|
+
/// runs on the concurrent pool rather than inheriting the JS thread.
|
|
65
|
+
internal func isConcurrentJSMember(_ decl: DeclSyntaxProtocol) -> Bool {
|
|
66
|
+
guard let attribute = memberAttributes(of: decl).firstAttribute(named: "JS") else {
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
return hasJSOption(attribute, named: "concurrent")
|
|
70
|
+
}
|
|
71
|
+
|
|
34
72
|
/// True if the type is written as an optional: `T?`, `T!`, or the explicit `Optional<T>`. Used to
|
|
35
73
|
/// decide argument requiredness (an optional parameter may be omitted) and record-field nullability.
|
|
36
74
|
internal func isOptionalType(_ type: TypeSyntax) -> Bool {
|
|
@@ -156,6 +194,7 @@ internal func memberHasJSAttribute(_ decl: DeclSyntaxProtocol) -> Bool {
|
|
|
156
194
|
|
|
157
195
|
/// Decides whether the macro should stamp `@JavaScriptActor` on a `@JS`-marked member.
|
|
158
196
|
/// The macro defers to the user when they've already chosen an isolation:
|
|
197
|
+
/// - the member opted out with `@JS(.concurrent)`
|
|
159
198
|
/// - the `nonisolated` modifier is present on the member
|
|
160
199
|
/// - any attribute whose name matches a known global actor (`@MainActor`, `@JavaScriptActor`)
|
|
161
200
|
/// or follows the `*Actor` naming convention is present on the member or its enclosing type
|
|
@@ -165,6 +204,10 @@ internal func shouldStampJavaScriptActor(
|
|
|
165
204
|
on member: DeclSyntaxProtocol,
|
|
166
205
|
enclosedBy enclosing: some DeclGroupSyntax
|
|
167
206
|
) -> Bool {
|
|
207
|
+
if isConcurrentJSMember(member) {
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
|
|
168
211
|
let modifiers = memberModifiers(of: member)
|
|
169
212
|
if modifiers.contains(where: { $0.name.text == "nonisolated" }) {
|
|
170
213
|
return false
|
|
@@ -261,6 +304,18 @@ internal func expressionType(_ type: String) -> String {
|
|
|
261
304
|
return type.dropLast() + "?"
|
|
262
305
|
}
|
|
263
306
|
|
|
307
|
+
/// The decode expression for a boundary type read from `valueExpression` (a `JavaScriptUnownedValue`),
|
|
308
|
+
/// as it appears after `try`. A free-form type (`Any`, `[Any]`, `[String: Any]`) can't conform to
|
|
309
|
+
/// `JavaScriptDecodable`, so it decodes through the dedicated `JavaScriptValue.decodeAny…` entry point
|
|
310
|
+
/// keyed to its shape; every other type decodes through its own static `decode`, spelled in expression
|
|
311
|
+
/// position (`T!` rewritten to `T?`).
|
|
312
|
+
internal func decodeCall(_ type: String, from valueExpression: String) -> String {
|
|
313
|
+
if let method = freeFormDecodeMethod(for: type) {
|
|
314
|
+
return "JavaScriptValue.\(method)(\(valueExpression), in: runtime)"
|
|
315
|
+
}
|
|
316
|
+
return "\(expressionType(type)).decode(\(valueExpression), in: runtime)"
|
|
317
|
+
}
|
|
318
|
+
|
|
264
319
|
// MARK: - @JS property collection
|
|
265
320
|
|
|
266
321
|
/// Collects the `@JS var` bindings of a declaration into `JSProperty` values for direct JSI binding.
|
|
@@ -314,3 +369,25 @@ internal func bindingIsSettable(_ binding: PatternBindingSyntax) -> Bool {
|
|
|
314
369
|
return false
|
|
315
370
|
}
|
|
316
371
|
}
|
|
372
|
+
|
|
373
|
+
/// True if the type's inheritance clause already lists a protocol with the given name. Matches
|
|
374
|
+
/// either the bare identifier (`Record`) or a qualified member access ending in the name
|
|
375
|
+
/// (`ExpoModulesCore.Record`). Used by the extension macros to skip a conformance the author already
|
|
376
|
+
/// spelled out. Works for `struct`, `class`, and `enum` declarations; any other declaration kind has no
|
|
377
|
+
/// inheritance clause to read and reports `false`.
|
|
378
|
+
internal func inheritsProtocol(named name: String, in declaration: some DeclGroupSyntax) -> Bool {
|
|
379
|
+
let inheritanceClause: InheritanceClauseSyntax?
|
|
380
|
+
if let structDecl = declaration.as(StructDeclSyntax.self) {
|
|
381
|
+
inheritanceClause = structDecl.inheritanceClause
|
|
382
|
+
} else if let classDecl = declaration.as(ClassDeclSyntax.self) {
|
|
383
|
+
inheritanceClause = classDecl.inheritanceClause
|
|
384
|
+
} else if let enumDecl = declaration.as(EnumDeclSyntax.self) {
|
|
385
|
+
inheritanceClause = enumDecl.inheritanceClause
|
|
386
|
+
} else {
|
|
387
|
+
return false
|
|
388
|
+
}
|
|
389
|
+
guard let inherited = inheritanceClause?.inheritedTypes else {
|
|
390
|
+
return false
|
|
391
|
+
}
|
|
392
|
+
return inherited.contains { baseIdentifier(of: $0.type) == name }
|
|
393
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import ExpoModulesScanner
|
|
2
|
+
import Foundation
|
|
1
3
|
import SwiftCompilerPlugin
|
|
2
4
|
import SwiftSyntaxMacros
|
|
3
5
|
|
|
4
|
-
@main
|
|
5
6
|
struct ExpoModulesMacrosPlugin: CompilerPlugin {
|
|
6
7
|
let providingMacros: [Macro.Type] = [
|
|
7
8
|
OptimizedFunctionAttachedMacro.self,
|
|
@@ -10,5 +11,22 @@ struct ExpoModulesMacrosPlugin: CompilerPlugin {
|
|
|
10
11
|
ExpoModuleMacro.self,
|
|
11
12
|
SharedObjectMacro.self,
|
|
12
13
|
RecordMacro.self,
|
|
14
|
+
UnionMacro.self,
|
|
13
15
|
]
|
|
14
16
|
}
|
|
17
|
+
|
|
18
|
+
/// The executable doubles as the scanner CLI. The compiler always launches a plugin executable
|
|
19
|
+
/// without arguments and speaks the plugin protocol over stdin, so any argument means a scanner
|
|
20
|
+
/// invocation (`ExpoModulesMacros-tool scan-modules <path>...`); with none, this starts the plugin
|
|
21
|
+
/// server exactly as `@main` on the `CompilerPlugin` type would.
|
|
22
|
+
@main
|
|
23
|
+
enum EntryPoint {
|
|
24
|
+
static func main() throws {
|
|
25
|
+
let arguments = Array(CommandLine.arguments.dropFirst())
|
|
26
|
+
if arguments.isEmpty {
|
|
27
|
+
try ExpoModulesMacrosPlugin.main()
|
|
28
|
+
} else {
|
|
29
|
+
exit(ScannerCLI.run(arguments: arguments))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -478,37 +478,6 @@ private func isExcludedByModifier(_ modifiers: DeclModifierListSyntax) -> Bool {
|
|
|
478
478
|
return false
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
-
/**
|
|
482
|
-
True if the type's inheritance clause already lists a protocol with the given name.
|
|
483
|
-
Matches either the bare identifier (`Record`) or a qualified member access ending in
|
|
484
|
-
the name (`ExpoModulesCore.Record`).
|
|
485
|
-
*/
|
|
486
|
-
private func inheritsProtocol(named name: String, in declaration: some DeclGroupSyntax) -> Bool {
|
|
487
|
-
let inheritanceClause: InheritanceClauseSyntax?
|
|
488
|
-
if let structDecl = declaration.as(StructDeclSyntax.self) {
|
|
489
|
-
inheritanceClause = structDecl.inheritanceClause
|
|
490
|
-
} else if let classDecl = declaration.as(ClassDeclSyntax.self) {
|
|
491
|
-
inheritanceClause = classDecl.inheritanceClause
|
|
492
|
-
} else {
|
|
493
|
-
return false
|
|
494
|
-
}
|
|
495
|
-
guard let inherited = inheritanceClause?.inheritedTypes else {
|
|
496
|
-
return false
|
|
497
|
-
}
|
|
498
|
-
for entry in inherited {
|
|
499
|
-
let typeSyntax = entry.type
|
|
500
|
-
if let identifier = typeSyntax.as(IdentifierTypeSyntax.self),
|
|
501
|
-
identifier.name.text == name {
|
|
502
|
-
return true
|
|
503
|
-
}
|
|
504
|
-
if let member = typeSyntax.as(MemberTypeSyntax.self),
|
|
505
|
-
member.name.text == name {
|
|
506
|
-
return true
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
return false
|
|
510
|
-
}
|
|
511
|
-
|
|
512
481
|
/**
|
|
513
482
|
True if the class declaration has any inheritance clause. Used as a heuristic for
|
|
514
483
|
whether the superclass also conforms to `Record` and provides the synthesized methods;
|
|
@@ -141,8 +141,14 @@ extension SharedObjectMacro: MemberAttributeMacro {
|
|
|
141
141
|
// `@Event(sync: true)` members are stamped alongside `@JS` ones: a sync event dispatches
|
|
142
142
|
// inline, so the isolation forces its call site onto the JS thread. Async events (the
|
|
143
143
|
// default) stay unstamped — their `emit` schedules onto the JS thread itself.
|
|
144
|
-
guard memberHasJSAttribute(member) || isSyncEventMember(member)
|
|
145
|
-
|
|
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 {
|
|
146
152
|
return []
|
|
147
153
|
}
|
|
148
154
|
return ["@JavaScriptActor"]
|
|
@@ -21,6 +21,12 @@ internal let javaScriptEncodableProtocolName = "JavaScriptEncodable"
|
|
|
21
21
|
/// support both directions, so the assertion requires the intersection.
|
|
22
22
|
internal let recordFieldProtocolName = "AnyArgument & JavaScriptDecodable & JavaScriptEncodable"
|
|
23
23
|
|
|
24
|
+
/// The constraint a `@Union` case payload type must satisfy: the union decodes by trying each payload's
|
|
25
|
+
/// `decode` and encodes through the matching payload's `encode`, so every alternative must convert in
|
|
26
|
+
/// both directions. Unlike a `@Record` field, a union never crosses the native-`Any` dictionary path, so
|
|
27
|
+
/// `AnyArgument` is not required.
|
|
28
|
+
internal let unionPayloadProtocolName = "JavaScriptDecodable & JavaScriptEncodable"
|
|
29
|
+
|
|
24
30
|
/// The protocol a type must conform to for `self.emit(event:…)` to resolve; core conforms
|
|
25
31
|
/// `BaseModule` and `SharedObject` to it. Asserted by `@Event` so attaching it to a type that can't
|
|
26
32
|
/// emit fails with a conformance diagnostic instead of an opaque "no member 'emit'" error.
|
|
@@ -116,11 +122,15 @@ internal func typeConformanceAssertions(
|
|
|
116
122
|
"""
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
/// The type to assert for a boundary type as written
|
|
120
|
-
///
|
|
121
|
-
///
|
|
122
|
-
///
|
|
125
|
+
/// The type to assert for a boundary type as written, or `nil` when nothing needs asserting: an
|
|
126
|
+
/// exactly-free-form type (handled by the binding's `decodeAny…` reroute) and a known-conforming
|
|
127
|
+
/// primitive both return `nil`; everything else is unwrapped of trailing optionals and asserted. An
|
|
128
|
+
/// *optional* free-form (`[String: Any]?`) isn't rerouted and doesn't conform, so it's kept and
|
|
129
|
+
/// asserted for a clean diagnostic. Shared with `@Event`.
|
|
123
130
|
internal func assertableBoundaryType(_ type: String) -> String? {
|
|
131
|
+
guard !isFreeFormBoundaryType(type) else {
|
|
132
|
+
return nil
|
|
133
|
+
}
|
|
124
134
|
let unwrapped = unwrappedOptional(type)
|
|
125
135
|
return knownConformingPrimitives.contains(unwrapped) ? nil : unwrapped
|
|
126
136
|
}
|
|
@@ -144,14 +154,12 @@ private func conformanceAssertionBody(
|
|
|
144
154
|
return lines.joined(separator: "\n")
|
|
145
155
|
}
|
|
146
156
|
|
|
147
|
-
///
|
|
148
|
-
///
|
|
149
|
-
/// while preserving first-seen order so a type is asserted once even when it appears more than once.
|
|
157
|
+
/// The distinct types to assert from a list, mapping each through `assertableBoundaryType` (which
|
|
158
|
+
/// drops what needs no assertion) and deduping in first-seen order.
|
|
150
159
|
private func distinctAssertableTypes(_ types: [String]) -> [String] {
|
|
151
160
|
var seen: Set<String> = []
|
|
152
161
|
var distinct: [String] = []
|
|
153
|
-
for type in types.
|
|
154
|
-
where !knownConformingPrimitives.contains(type) && seen.insert(type).inserted {
|
|
162
|
+
for type in types.compactMap(assertableBoundaryType) where seen.insert(type).inserted {
|
|
155
163
|
distinct.append(type)
|
|
156
164
|
}
|
|
157
165
|
return distinct
|