expo-modules-macros 0.29.0 → 0.30.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/AGENTS.md +2 -2
- package/README.md +35 -4
- package/apple/ExpoModulesMacros +0 -0
- package/apple/Sources/ExpoModulesCLI/CLI.swift +46 -9
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/ExpandReport.swift +37 -1
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/MacroExpander.swift +117 -26
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/RunExpand.swift +20 -10
- package/apple/Sources/ExpoModulesCLI/Commands/Fix/RunFix.swift +228 -0
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ExportedSurface.swift +1 -1
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/SurfaceVisitor.swift +8 -10
- package/apple/Sources/ExpoModulesCLI/Commands/ScanModules/ScanModules.swift +48 -34
- package/apple/Sources/ExpoModulesMacrosCore/Macros/EventMacro.swift +3 -3
- package/apple/Sources/ExpoModulesMacrosCore/Macros/ExpoModuleMacro.swift +8 -1
- package/apple/Sources/ExpoModulesMacrosCore/Macros/JSMacro.swift +54 -1
- package/apple/Sources/ExpoModulesMacrosCore/Macros/SharedObjectMacro.swift +9 -2
- package/apple/Sources/ExpoModulesMacrosCore/Macros/ViewPropsMacro.swift +36 -6
- package/apple/Sources/ExpoModulesMacrosCore/Support/MacroHelpers.swift +11 -25
- package/apple/Sources/ExpoModulesMacrosCore/Support/PublicAccess.swift +105 -0
- package/build/types.d.ts +15 -6
- package/package.json +5 -5
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#if canImport(FoundationEssentials)
|
|
2
|
+
import FoundationEssentials
|
|
3
|
+
#else
|
|
4
|
+
import Foundation
|
|
5
|
+
#endif
|
|
6
|
+
|
|
7
|
+
/// The options of `fix`, parsed by `ScannerCLI`.
|
|
8
|
+
struct FixOptions: Equatable {
|
|
9
|
+
var paths: [String] = []
|
|
10
|
+
var symbols: [String] = []
|
|
11
|
+
var dryRun = false
|
|
12
|
+
var onlyErrors = false
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// A Fix-It that `fix` applied or skipped, at the location of its diagnostic.
|
|
16
|
+
struct FixItReport: Equatable {
|
|
17
|
+
let line: Int
|
|
18
|
+
let column: Int
|
|
19
|
+
let message: String
|
|
20
|
+
|
|
21
|
+
/// Why `fix` skipped the Fix-It, or `nil` when it applied it.
|
|
22
|
+
let skipReason: String?
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// The result of applying the Fix-Its of one file.
|
|
26
|
+
struct FixedSource: Equatable {
|
|
27
|
+
/// The source with the applied Fix-Its.
|
|
28
|
+
let source: String
|
|
29
|
+
let fixIts: [FixItReport]
|
|
30
|
+
|
|
31
|
+
/// Whether `fixSource` stopped after `maxFixPasses` passes while the last pass still applied a
|
|
32
|
+
/// Fix-It, so the source can have more.
|
|
33
|
+
var reachedPassLimit = false
|
|
34
|
+
|
|
35
|
+
var applied: [FixItReport] {
|
|
36
|
+
fixIts.filter { $0.skipReason == nil }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
extension Scanner {
|
|
41
|
+
/// Runs `fix`: expands the macros in each file like `expand` and applies the Fix-Its of their
|
|
42
|
+
/// diagnostics to the file, in place (see `fixSource`). It doesn't format the file.
|
|
43
|
+
///
|
|
44
|
+
/// Each applied Fix-It is printed to stdout as `<file>:<line>:<column>: fix-it: <message>` after its
|
|
45
|
+
/// file is written, followed by a summary, and each skipped one to stderr as a warning. With
|
|
46
|
+
/// `--dry-run`, the files aren't written. A file that can't be written is reported and the others are
|
|
47
|
+
/// still fixed. The exit code is `0` also when a Fix-It is skipped, `1` when a file can't be written,
|
|
48
|
+
/// and `2` for an input that `expand` doesn't take.
|
|
49
|
+
static func runFix(options: FixOptions, expander: MacroExpander) -> Int32 {
|
|
50
|
+
let sources: [(source: String, expanded: ExpandedFile)]
|
|
51
|
+
switch expandSources(options: ExpandOptions(paths: options.paths, symbols: options.symbols), expander: expander) {
|
|
52
|
+
case .success(let expanded):
|
|
53
|
+
sources = expanded
|
|
54
|
+
case .failure(let error):
|
|
55
|
+
writeToStandardError("error: \(error.message)\n")
|
|
56
|
+
return 2
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
var appliedCount = 0
|
|
60
|
+
var fixedFiles = 0
|
|
61
|
+
var failed = false
|
|
62
|
+
for (source, expanded) in sources {
|
|
63
|
+
let fixed = fixSource(
|
|
64
|
+
source,
|
|
65
|
+
expanded: expanded,
|
|
66
|
+
symbols: options.symbols,
|
|
67
|
+
onlyErrors: options.onlyErrors,
|
|
68
|
+
expander: expander
|
|
69
|
+
)
|
|
70
|
+
writeToStandardError(renderSkippedFixIts(fixed, file: expanded.file))
|
|
71
|
+
guard !fixed.applied.isEmpty else {
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
if !options.dryRun {
|
|
75
|
+
do {
|
|
76
|
+
try writeSource(fixed.source, toFile: expanded.file)
|
|
77
|
+
} catch {
|
|
78
|
+
writeToStandardError("error: could not write '\(expanded.file)': \(error)\n")
|
|
79
|
+
failed = true
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
writeToStandardOutput(renderAppliedFixIts(fixed, file: expanded.file))
|
|
84
|
+
appliedCount += fixed.applied.count
|
|
85
|
+
fixedFiles += 1
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
writeToStandardOutput(renderFixSummary(applied: appliedCount, files: fixedFiles, dryRun: options.dryRun))
|
|
89
|
+
return failed ? 1 : 0
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// Writes `source` to the file at `path` in place, so a symbolic link keeps pointing to the file and a
|
|
94
|
+
/// hard link keeps sharing it, and a writable file can be written in a read-only directory. Keeps the
|
|
95
|
+
/// UTF-8 byte order mark of the file, which the source doesn't have because reading it as a `String`
|
|
96
|
+
/// removes it.
|
|
97
|
+
func writeSource(_ source: String, toFile path: String) throws {
|
|
98
|
+
let url = URL(fileURLWithPath: path)
|
|
99
|
+
let byteOrderMark: [UInt8] = [0xEF, 0xBB, 0xBF]
|
|
100
|
+
let hasByteOrderMark = try Data(contentsOf: url).starts(with: byteOrderMark)
|
|
101
|
+
try Data((hasByteOrderMark ? byteOrderMark : []) + Array(source.utf8)).write(to: url)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// The most times that `fixSource` expands a file again after applying Fix-Its.
|
|
105
|
+
let maxFixPasses = 10
|
|
106
|
+
|
|
107
|
+
/// Applies the Fix-Its of `expanded`, the expansion of `source`, to `source`. A macro can stop at its
|
|
108
|
+
/// first error, so after applying Fix-Its, this expands the result again and applies the new Fix-Its,
|
|
109
|
+
/// until none applies or after `maxFixPasses` passes. The location of an applied Fix-It is in the
|
|
110
|
+
/// source of its pass. The skipped Fix-Its are those of the last pass. With `onlyErrors`, the Fix-Its
|
|
111
|
+
/// of warnings and other diagnostics that aren't errors are left out.
|
|
112
|
+
func fixSource(
|
|
113
|
+
_ source: String,
|
|
114
|
+
expanded: ExpandedFile,
|
|
115
|
+
symbols: [String],
|
|
116
|
+
onlyErrors: Bool = false,
|
|
117
|
+
expander: MacroExpander
|
|
118
|
+
) -> FixedSource {
|
|
119
|
+
let selected = { (diagnostics: [ExpansionDiagnostic]) in
|
|
120
|
+
onlyErrors ? diagnostics.filter { $0.severity == .error } : diagnostics
|
|
121
|
+
}
|
|
122
|
+
var fixed = applyFixIts(to: source, diagnostics: selected(expanded.diagnostics))
|
|
123
|
+
var applied = fixed.applied
|
|
124
|
+
for _ in 1..<maxFixPasses where !fixed.applied.isEmpty {
|
|
125
|
+
let diagnostics = expander.expand(fixed.source, expanded.file, symbols).diagnostics
|
|
126
|
+
fixed = applyFixIts(to: fixed.source, diagnostics: selected(diagnostics))
|
|
127
|
+
applied += fixed.applied
|
|
128
|
+
}
|
|
129
|
+
return FixedSource(
|
|
130
|
+
source: fixed.source,
|
|
131
|
+
fixIts: applied + fixed.fixIts.filter { $0.skipReason != nil },
|
|
132
|
+
reachedPassLimit: !fixed.applied.isEmpty
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// Applies the Fix-Its of `diagnostics` to `source`, which is the file that they were reported for.
|
|
137
|
+
///
|
|
138
|
+
/// A diagnostic with more than one Fix-It is skipped, because they are alternatives. So is a Fix-It
|
|
139
|
+
/// whose edits aren't in the file, or whose edits overlap the edits of a Fix-It before it. A Fix-It
|
|
140
|
+
/// with the same edits as one before it, which a macro can report twice, is left out. A diagnostic
|
|
141
|
+
/// without `fixItEdits`, such as a syntax error, is left out too.
|
|
142
|
+
func applyFixIts(to source: String, diagnostics: [ExpansionDiagnostic]) -> FixedSource {
|
|
143
|
+
let text = Array(source.utf8)
|
|
144
|
+
var accepted: [ExpansionEdit] = []
|
|
145
|
+
var reports: [FixItReport] = []
|
|
146
|
+
|
|
147
|
+
for diagnostic in diagnostics
|
|
148
|
+
where !diagnostic.fixIts.isEmpty && diagnostic.fixItEdits.count == diagnostic.fixIts.count {
|
|
149
|
+
let report = { (message: String, reason: String?) in
|
|
150
|
+
reports.append(
|
|
151
|
+
FixItReport(line: diagnostic.line, column: diagnostic.column, message: message, skipReason: reason)
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
guard diagnostic.fixIts.count == 1, let message = diagnostic.fixIts.first, let edits = diagnostic.fixItEdits.first
|
|
155
|
+
else {
|
|
156
|
+
for message in diagnostic.fixIts {
|
|
157
|
+
report(message, "the diagnostic has \(diagnostic.fixIts.count) Fix-Its to choose from")
|
|
158
|
+
}
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
if !edits.isEmpty && edits.allSatisfy(accepted.contains) {
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
guard !edits.isEmpty, edits.allSatisfy({ matches($0, in: text) }) else {
|
|
165
|
+
report(message, "its edits aren't in the file")
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
let overlaps = edits.enumerated().contains { index, edit in
|
|
169
|
+
(accepted + edits.prefix(index)).contains { overlap(edit, $0) }
|
|
170
|
+
}
|
|
171
|
+
guard !overlaps else {
|
|
172
|
+
report(message, "it overlaps another Fix-It")
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
accepted += edits
|
|
176
|
+
report(message, nil)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
var fixed = text
|
|
180
|
+
for edit in accepted.sorted(by: { $0.offset > $1.offset }) {
|
|
181
|
+
fixed.replaceSubrange(edit.offset..<(edit.offset + edit.original.utf8.count), with: Array(edit.replacement.utf8))
|
|
182
|
+
}
|
|
183
|
+
return FixedSource(source: String(decoding: fixed, as: UTF8.self), fixIts: reports)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// Whether `text` has the original text of `edit` at its offset.
|
|
187
|
+
private func matches(_ edit: ExpansionEdit, in text: [UInt8]) -> Bool {
|
|
188
|
+
let original = Array(edit.original.utf8)
|
|
189
|
+
return edit.offset >= 0 && edit.offset + original.count <= text.count
|
|
190
|
+
&& text[edit.offset..<(edit.offset + original.count)].elementsEqual(original)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/// Whether two edits change the same bytes, or insert at the same offset, where their order would
|
|
194
|
+
/// matter.
|
|
195
|
+
private func overlap(_ a: ExpansionEdit, _ b: ExpansionEdit) -> Bool {
|
|
196
|
+
let aEnd = a.offset + a.original.utf8.count
|
|
197
|
+
let bEnd = b.offset + b.original.utf8.count
|
|
198
|
+
return a.offset == b.offset || (a.offset < bEnd && b.offset < aEnd)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/// The applied Fix-Its of `fixed`, one line each.
|
|
202
|
+
func renderAppliedFixIts(_ fixed: FixedSource, file: String) -> String {
|
|
203
|
+
fixed.applied.map { "\(file):\($0.line):\($0.column): fix-it: \($0.message)\n" }.joined()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/// The warnings for the skipped Fix-Its of `fixed`, and for reaching the pass limit.
|
|
207
|
+
func renderSkippedFixIts(_ fixed: FixedSource, file: String) -> String {
|
|
208
|
+
var text = ""
|
|
209
|
+
for fixIt in fixed.fixIts {
|
|
210
|
+
if let reason = fixIt.skipReason {
|
|
211
|
+
text += "\(file):\(fixIt.line):\(fixIt.column): warning: skipped fix-it '\(fixIt.message)': \(reason)\n"
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if fixed.reachedPassLimit {
|
|
215
|
+
text += "\(file): warning: stopped after \(maxFixPasses) passes; run fix again for the Fix-Its left\n"
|
|
216
|
+
}
|
|
217
|
+
return text
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/// The summary line of `fix`, for example `Applied 3 Fix-Its in 2 files.`
|
|
221
|
+
func renderFixSummary(applied: Int, files: Int, dryRun: Bool) -> String {
|
|
222
|
+
guard applied > 0 else {
|
|
223
|
+
return "No Fix-Its to apply.\n"
|
|
224
|
+
}
|
|
225
|
+
let fixIts = "\(applied) Fix-It\(applied == 1 ? "" : "s")"
|
|
226
|
+
let fileCount = "\(files) file\(files == 1 ? "" : "s")"
|
|
227
|
+
return "\(dryRun ? "Would apply" : "Applied") \(fixIts) in \(fileCount).\n"
|
|
228
|
+
}
|
|
@@ -207,7 +207,7 @@ struct ExportedEvent: Encodable, Equatable {
|
|
|
207
207
|
/// The single payload parameter's type, or `nil` for a no-payload `() -> Void` event.
|
|
208
208
|
var payload: TypeNode?
|
|
209
209
|
|
|
210
|
-
/// `@Event(sync
|
|
210
|
+
/// `@Event(.sync)`, dispatching inline on the JS thread instead of scheduling.
|
|
211
211
|
let isSync: Bool
|
|
212
212
|
|
|
213
213
|
/// The spelled access modifier, or `nil` when none is written.
|
|
@@ -285,7 +285,7 @@ final class SurfaceVisitor: SyntaxVisitor {
|
|
|
285
285
|
return []
|
|
286
286
|
}
|
|
287
287
|
let override = stringArgument(of: attribute)
|
|
288
|
-
let isSync =
|
|
288
|
+
let isSync = hasEventOption(attribute, named: "sync")
|
|
289
289
|
var result: [ExportedEvent] = []
|
|
290
290
|
|
|
291
291
|
for binding in varDecl.bindings {
|
|
@@ -704,18 +704,16 @@ private func isVoidEventReturn(_ type: TypeSyntax) -> Bool {
|
|
|
704
704
|
return text == "Void" || text == "()" || text == "Swift.Void"
|
|
705
705
|
}
|
|
706
706
|
|
|
707
|
-
///
|
|
708
|
-
|
|
707
|
+
/// True if an `@Event` attribute lists the given option member (`@Event(.sync)` or
|
|
708
|
+
/// `@Event("name", EventOptions.sync)`). Like `hasOption` in the macros, but without the array-literal
|
|
709
|
+
/// branch, which `EventOptions...` doesn't accept.
|
|
710
|
+
private func hasEventOption(_ attribute: AttributeSyntax, named option: String) -> Bool {
|
|
709
711
|
guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else {
|
|
710
|
-
return
|
|
712
|
+
return false
|
|
711
713
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
return nil
|
|
715
|
-
}
|
|
716
|
-
return literal.literal.tokenKind == .keyword(.true)
|
|
714
|
+
return arguments.contains { argument in
|
|
715
|
+
argument.expression.as(MemberAccessExprSyntax.self)?.declName.baseName.text == option
|
|
717
716
|
}
|
|
718
|
-
return nil
|
|
719
717
|
}
|
|
720
718
|
|
|
721
719
|
/// The name in a `@JS` attribute's `.withAsync("name")` option, in the arguments or inside an array
|
|
@@ -34,19 +34,19 @@ public enum Scanner {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/// One
|
|
38
|
-
///
|
|
39
|
-
///
|
|
40
|
-
///
|
|
41
|
-
///
|
|
42
|
-
/// per-member detail instead.
|
|
43
|
-
struct
|
|
44
|
-
/// The Swift class name
|
|
37
|
+
/// One class in the `scan-modules` output: an `@ExpoModule` class in `modules`, or a `@SharedObject`
|
|
38
|
+
/// class in `sharedObjects`. Trimmed to what `expo-modules-autolinking` needs to register the class:
|
|
39
|
+
/// the Swift class name, the JS name it registers under, the platforms that include it, and the file
|
|
40
|
+
/// it's in. The richer fields the visitor captures (declaration kind, raw macro arguments,
|
|
41
|
+
/// line/column) are dropped here, because this command doesn't need them (the macro is always on a
|
|
42
|
+
/// class) and the deep `scan-exports` surface carries the richer per-member detail instead.
|
|
43
|
+
struct ScannedClass: Codable, Equatable {
|
|
44
|
+
/// The Swift class name.
|
|
45
45
|
let name: String
|
|
46
46
|
|
|
47
|
-
/// The fully-resolved JS
|
|
48
|
-
/// class name. Resolved here (rather than left `nil`) so it matches how the
|
|
49
|
-
/// and the consumer never has to apply the fallback itself.
|
|
47
|
+
/// The fully-resolved JS name: the `@ExpoModule("Foo")` or `@SharedObject("Foo")` override when
|
|
48
|
+
/// present, otherwise the class name. Resolved here (rather than left `nil`) so it matches how the
|
|
49
|
+
/// macro derives the name and the consumer never has to apply the fallback itself.
|
|
50
50
|
let jsName: String
|
|
51
51
|
|
|
52
52
|
/// The class's spelled access modifier (`open`, `public`, `package`, `fileprivate`, `private`),
|
|
@@ -56,20 +56,21 @@ struct ScannedModule: Codable, Equatable {
|
|
|
56
56
|
let accessLevel: String
|
|
57
57
|
|
|
58
58
|
/// The Apple OSes whose builds include this class, spelled as `os(...)` spells them and compared
|
|
59
|
-
/// case-sensitively. An unconditional
|
|
59
|
+
/// case-sensitively. An unconditional class lists every OS. Empty means no build is known to
|
|
60
60
|
/// include it, because the enclosing conditions depend on flags not asserted with `--define` or
|
|
61
61
|
/// on conditions a static scan cannot answer (those are reported in `warnings`). The consumer
|
|
62
62
|
/// must not assume such a class exists.
|
|
63
63
|
let platforms: [String]
|
|
64
64
|
|
|
65
|
-
/// Source file the
|
|
65
|
+
/// Source file the class was found in, relative to the path the scanner was invoked with.
|
|
66
66
|
let file: String
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/// Version of the `scan-modules` output shape. Bumped on any breaking change to the envelope or to
|
|
70
|
-
/// `
|
|
70
|
+
/// `ScannedClass`, so `expo-modules-autolinking` can verify it understands the output before
|
|
71
71
|
/// trusting it (and fall back to config-declared modules when it doesn't). Version 2 added the
|
|
72
|
-
/// per-module `platforms` list and made the module list platform-agnostic.
|
|
72
|
+
/// per-module `platforms` list and made the module list platform-agnostic. A new field that older
|
|
73
|
+
/// consumers can ignore, such as `sharedObjects`, doesn't change the version.
|
|
73
74
|
let scanModulesSchemaVersion = 2
|
|
74
75
|
|
|
75
76
|
/// The `scan-modules` result: the detected modules plus the stats describing the run. Encoded as the
|
|
@@ -77,7 +78,11 @@ let scanModulesSchemaVersion = 2
|
|
|
77
78
|
/// serve different consumers and don't share an envelope.)
|
|
78
79
|
struct ScanModulesResult: Codable, Equatable {
|
|
79
80
|
let schemaVersion: Int
|
|
80
|
-
let modules: [
|
|
81
|
+
let modules: [ScannedClass]
|
|
82
|
+
|
|
83
|
+
/// The top-level `@SharedObject` classes, in the same order and with the same platform rules as
|
|
84
|
+
/// `modules`.
|
|
85
|
+
let sharedObjects: [ScannedClass]
|
|
81
86
|
|
|
82
87
|
/// Warnings for `#if` conditions the scan couldn't answer statically (see `ScanWarning`). Carried
|
|
83
88
|
/// in the report rather than on stderr so the consumer can attach them to its own output.
|
|
@@ -97,25 +102,28 @@ private let platformUniverse: [(condition: String, reported: String)] = [
|
|
|
97
102
|
(condition: "visionOS", reported: "visionOS"),
|
|
98
103
|
]
|
|
99
104
|
|
|
100
|
-
/// Scans the given paths for top-level `@ExpoModule` types and returns every
|
|
105
|
+
/// Scans the given paths for top-level `@ExpoModule` and `@SharedObject` types and returns every one
|
|
106
|
+
/// found in any
|
|
101
107
|
/// `#if` branch (in file then source order), each with the platforms whose builds include it, plus
|
|
102
108
|
/// the stats for the run — the `scan-modules` command. Kept separate from the public entry (and
|
|
103
109
|
/// `internal`) so tests can drive it without going through argv/stdout.
|
|
104
110
|
///
|
|
105
111
|
/// Each file is parsed once and walked once per platform (plus once unconditionally to enumerate
|
|
106
|
-
/// every
|
|
112
|
+
/// every type): a type's `platforms` are the OSes whose evaluated walk reached it, given the
|
|
107
113
|
/// asserted `defines`. The scanner reports the facts; filtering to the platform being linked is the
|
|
108
114
|
/// consumer's call.
|
|
109
115
|
func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResult {
|
|
110
|
-
var modules: [
|
|
116
|
+
var modules: [ScannedClass] = []
|
|
117
|
+
var sharedObjects: [ScannedClass] = []
|
|
111
118
|
var warnings: [ScanWarning] = []
|
|
112
119
|
var seenWarnings = Set<ScanWarning>()
|
|
113
120
|
|
|
114
|
-
let
|
|
121
|
+
let macros: Set<DetectedMacro> = [.expoModule, .sharedObject]
|
|
122
|
+
let stats = scanFiles(paths: paths, macros: macros) { source, file in
|
|
115
123
|
let tree = Parser.parse(source: source)
|
|
116
124
|
|
|
117
|
-
// The unconditional walk enumerates every
|
|
118
|
-
let allModules = DetectionVisitor(file: file, tree: tree, detectedMacros:
|
|
125
|
+
// The unconditional walk enumerates every type in the file, in source order.
|
|
126
|
+
let allModules = DetectionVisitor(file: file, tree: tree, detectedMacros: macros, configuration: nil)
|
|
119
127
|
allModules.walk(tree)
|
|
120
128
|
|
|
121
129
|
// One evaluated walk per OS attributes each module to the platforms that include it. The walks
|
|
@@ -126,7 +134,7 @@ func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResul
|
|
|
126
134
|
let visitor = DetectionVisitor(
|
|
127
135
|
file: file,
|
|
128
136
|
tree: tree,
|
|
129
|
-
detectedMacros:
|
|
137
|
+
detectedMacros: macros,
|
|
130
138
|
configuration: configuration
|
|
131
139
|
)
|
|
132
140
|
visitor.walk(tree)
|
|
@@ -140,24 +148,30 @@ func scanModules(paths: [String], defines: Set<String> = []) -> ScanModulesResul
|
|
|
140
148
|
}
|
|
141
149
|
}
|
|
142
150
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
// the
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
)
|
|
151
|
+
// The macros reject anything but a class, so a struct with one of them is never registered.
|
|
152
|
+
for detection in allModules.detections where detection.declarationKind == "class" {
|
|
153
|
+
// Resolve the JS name the way the macro does: explicit `@ExpoModule("Foo")` or
|
|
154
|
+
// `@SharedObject("Foo")` override, else the class name.
|
|
155
|
+
let scanned = ScannedClass(
|
|
156
|
+
name: detection.name,
|
|
157
|
+
jsName: detection.jsName ?? detection.name,
|
|
158
|
+
accessLevel: detection.accessLevel,
|
|
159
|
+
platforms: platformsByDetection[detectionKey(detection)] ?? [],
|
|
160
|
+
file: detection.file
|
|
154
161
|
)
|
|
162
|
+
switch detection.macro {
|
|
163
|
+
case .sharedObject:
|
|
164
|
+
sharedObjects.append(scanned)
|
|
165
|
+
default:
|
|
166
|
+
modules.append(scanned)
|
|
167
|
+
}
|
|
155
168
|
}
|
|
156
169
|
}
|
|
157
170
|
|
|
158
171
|
return ScanModulesResult(
|
|
159
172
|
schemaVersion: scanModulesSchemaVersion,
|
|
160
173
|
modules: modules,
|
|
174
|
+
sharedObjects: sharedObjects,
|
|
161
175
|
warnings: warnings,
|
|
162
176
|
stats: stats
|
|
163
177
|
)
|
|
@@ -32,7 +32,7 @@ import SwiftSyntaxMacros
|
|
|
32
32
|
/// also self-contained: `@ExpoModule`/`@SharedObject` neither collect `@Event` members nor register
|
|
33
33
|
/// their names anywhere.
|
|
34
34
|
///
|
|
35
|
-
/// `@Event(sync
|
|
35
|
+
/// `@Event(.sync)` opts into **synchronous dispatch**: the closure calls `emitSync` (inline
|
|
36
36
|
/// conversion + dispatch, no scheduling) instead of `emit`, and `@ExpoModule`/`@SharedObject` stamp
|
|
37
37
|
/// the member `@JavaScriptActor` so the compiler forces the call site onto the JS thread, the
|
|
38
38
|
/// inverse of the async default. The isolation is on the property access, so it guards the inline
|
|
@@ -127,7 +127,7 @@ private struct EventMember {
|
|
|
127
127
|
let jsName: String
|
|
128
128
|
/// The payload type as written, or `nil` for a no-payload `() -> Void` event.
|
|
129
129
|
let payloadType: String?
|
|
130
|
-
/// Whether the event dispatches synchronously (`@Event(sync
|
|
130
|
+
/// Whether the event dispatches synchronously (`@Event(.sync)`) via `emitSync`.
|
|
131
131
|
let isSync: Bool
|
|
132
132
|
|
|
133
133
|
var hasPayload: Bool {
|
|
@@ -212,7 +212,7 @@ private func validatedEvent(
|
|
|
212
212
|
swiftName: swiftName,
|
|
213
213
|
jsName: jsNameArgument(of: node) ?? defaultEventName(for: withoutBackticks(swiftName)),
|
|
214
214
|
payloadType: functionType.parameters.first?.type.trimmedDescription,
|
|
215
|
-
isSync:
|
|
215
|
+
isSync: hasOption(node, named: "sync")
|
|
216
216
|
)
|
|
217
217
|
}
|
|
218
218
|
|
|
@@ -38,6 +38,13 @@ public struct ExpoModuleMacro: MemberMacro {
|
|
|
38
38
|
guard let classDecl = declaration.as(ClassDeclSyntax.self) else {
|
|
39
39
|
throw MacroExpansionErrorMessage("@ExpoModule can only be applied to a class")
|
|
40
40
|
}
|
|
41
|
+
diagnoseNonPublicClass(
|
|
42
|
+
classDecl,
|
|
43
|
+
macroName: "@ExpoModule",
|
|
44
|
+
reason: "register it",
|
|
45
|
+
idPrefix: "expomodule",
|
|
46
|
+
in: context
|
|
47
|
+
)
|
|
41
48
|
|
|
42
49
|
// The module name is fully resolved here (the explicit `@ExpoModule("…")` argument, else the
|
|
43
50
|
// class name) and emitted as a non-optional `_jsName` static below — so the
|
|
@@ -160,7 +167,7 @@ extension ExpoModuleMacro: MemberAttributeMacro {
|
|
|
160
167
|
// checked at compile time. Skipped when the member already chose an isolation
|
|
161
168
|
// (`nonisolated` or another global actor), or when `.concurrent` or `.withAsync("name")` runs
|
|
162
169
|
// it on the concurrent pool. See `shouldStampJavaScriptActor`.
|
|
163
|
-
// `@Event(sync
|
|
170
|
+
// `@Event(.sync)` members get the stamp too: a sync event dispatches inline, so the
|
|
164
171
|
// isolation forces its call site onto the JS thread. Async events (the default) are
|
|
165
172
|
// deliberately left unstamped — their `emit` schedules onto the JS thread itself.
|
|
166
173
|
if memberHasJSAttribute(member) || isSyncEventMember(member),
|
|
@@ -36,6 +36,7 @@ public struct JSMacro: PeerMacro {
|
|
|
36
36
|
diagnoseClosureTypes(in: declaration, in: context)
|
|
37
37
|
diagnoseConcurrentOption(of: node, on: declaration, in: context)
|
|
38
38
|
diagnoseAsyncVariantOption(of: node, on: declaration, in: context)
|
|
39
|
+
diagnoseJavaScriptValuesOffThread(of: node, on: declaration, in: context)
|
|
39
40
|
|
|
40
41
|
// In an `@ExpoModule` or `@SharedObject` class, the class macro emits the assertion instead: it sees
|
|
41
42
|
// the type aliases declared in the class body, which this macro can't (see `TupleTypeAliases`).
|
|
@@ -83,7 +84,7 @@ private func diagnoseConcurrentOption(
|
|
|
83
84
|
on declaration: some DeclSyntaxProtocol,
|
|
84
85
|
in context: some MacroExpansionContext
|
|
85
86
|
) {
|
|
86
|
-
guard
|
|
87
|
+
guard hasOption(node, named: "concurrent") else {
|
|
87
88
|
return
|
|
88
89
|
}
|
|
89
90
|
if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
|
|
@@ -248,6 +249,58 @@ private func insertAsyncFixIt(for funcDecl: FunctionDeclSyntax) -> FixIt {
|
|
|
248
249
|
/// `consuming`, `sending`) are fine: the binding passes the decoded value under whichever convention
|
|
249
250
|
/// the parameter asks for. The specifier is left off the decode and the assertion (see
|
|
250
251
|
/// `SpecifiedType`); a parameter reported here is also left out of the assertion.
|
|
252
|
+
/// The JSI types that own an engine value and release it wherever they are dropped. Such a value is
|
|
253
|
+
/// valid only on the JavaScript thread. `JavaScriptPromise` releases its value on the JavaScript
|
|
254
|
+
/// thread, and `JavaScriptRef` and `JavaScriptCallback` exist to carry a value across threads, so they
|
|
255
|
+
/// aren't listed.
|
|
256
|
+
private let javaScriptThreadBoundTypes: Set<String> = [
|
|
257
|
+
"JavaScriptValue", "JavaScriptObject", "JavaScriptFunction", "JavaScriptArray", "JavaScriptArrayBuffer",
|
|
258
|
+
"JavaScriptTypedArray", "JavaScriptBigInt", "JavaScriptWeakObject",
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
/// Emits the diagnostic for a JSI value parameter of a `@JS(.concurrent)` function or of the variant
|
|
262
|
+
/// that `@JS(.withAsync("name"))` adds. Both run the body on the concurrent thread pool, never on the
|
|
263
|
+
/// JavaScript thread, so the body can't use the value, and the value is released off the JavaScript
|
|
264
|
+
/// thread too.
|
|
265
|
+
private func diagnoseJavaScriptValuesOffThread(
|
|
266
|
+
of node: AttributeSyntax,
|
|
267
|
+
on declaration: some DeclSyntaxProtocol,
|
|
268
|
+
in context: some MacroExpansionContext
|
|
269
|
+
) {
|
|
270
|
+
guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else {
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
let option: String
|
|
274
|
+
let reason: String
|
|
275
|
+
if hasOption(node, named: "concurrent") {
|
|
276
|
+
option = ".concurrent"
|
|
277
|
+
reason = "'.concurrent' runs the body on the concurrent thread pool"
|
|
278
|
+
} else if asyncVariantOption(of: node) != nil {
|
|
279
|
+
option = ".withAsync"
|
|
280
|
+
reason = "the variant that '.withAsync' adds runs the function on the concurrent thread pool"
|
|
281
|
+
} else {
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
for parameter in funcDecl.signature.parameterClause.parameters {
|
|
285
|
+
let type = SpecifiedType(parameter.type).type
|
|
286
|
+
guard let name = baseIdentifier(of: optionalWrappedType(type) ?? type), javaScriptThreadBoundTypes.contains(name)
|
|
287
|
+
else {
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
let parameterName = (parameter.secondName ?? parameter.firstName).text
|
|
291
|
+
context.diagnose(
|
|
292
|
+
Diagnostic(
|
|
293
|
+
node: parameter.type,
|
|
294
|
+
message: JSDiagnosticMessage(
|
|
295
|
+
"'\(parameterName)' can't be a '\(name)' in a '\(option)' function: \(reason), and a JavaScript value is valid only on the JavaScript thread. Take a native type that the value decodes to, such as a @Record or [String: Any], or remove '\(option)'.",
|
|
296
|
+
id: "js-javascript-value-off-thread",
|
|
297
|
+
severity: .error
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
251
304
|
private func diagnoseParameterSpecifiers(
|
|
252
305
|
in declaration: some DeclSyntaxProtocol,
|
|
253
306
|
in context: some MacroExpansionContext
|
|
@@ -8,7 +8,7 @@ import SwiftSyntaxMacros
|
|
|
8
8
|
/// is emitted as a `_jsName` override.
|
|
9
9
|
///
|
|
10
10
|
/// @SharedObject
|
|
11
|
-
/// final class Cache: SharedObject {
|
|
11
|
+
/// public final class Cache: SharedObject {
|
|
12
12
|
/// @JS
|
|
13
13
|
/// init(name: String) { self.name = name }
|
|
14
14
|
///
|
|
@@ -34,6 +34,13 @@ public struct SharedObjectMacro: MemberMacro {
|
|
|
34
34
|
guard let classDecl = declaration.as(ClassDeclSyntax.self) else {
|
|
35
35
|
throw MacroExpansionErrorMessage("@SharedObject can only be applied to a class")
|
|
36
36
|
}
|
|
37
|
+
diagnoseNonPublicClass(
|
|
38
|
+
classDecl,
|
|
39
|
+
macroName: "@SharedObject",
|
|
40
|
+
reason: "export it",
|
|
41
|
+
idPrefix: "sharedobject",
|
|
42
|
+
in: context
|
|
43
|
+
)
|
|
37
44
|
|
|
38
45
|
// The superclass isn't checked here: the macro sees only this declaration, so it can't tell an
|
|
39
46
|
// indirect subclass (`class Thumbnail: ImageRef`) from an unrelated class. The compiler reports a
|
|
@@ -164,7 +171,7 @@ extension SharedObjectMacro: MemberAttributeMacro {
|
|
|
164
171
|
providingAttributesFor member: some DeclSyntaxProtocol,
|
|
165
172
|
in context: some MacroExpansionContext
|
|
166
173
|
) throws -> [AttributeSyntax] {
|
|
167
|
-
// `@Event(sync
|
|
174
|
+
// `@Event(.sync)` members are stamped alongside `@JS` ones: a sync event dispatches
|
|
168
175
|
// inline, so the isolation forces its call site onto the JS thread. Async events (the
|
|
169
176
|
// default) stay unstamped — their `emit` schedules onto the JS thread itself.
|
|
170
177
|
guard memberHasJSAttribute(member) || isSyncEventMember(member) else {
|
|
@@ -139,23 +139,37 @@ private func fieldAttributeDiagnostic(
|
|
|
139
139
|
for attribute: AttributeSyntax,
|
|
140
140
|
on varDecl: VariableDeclSyntax
|
|
141
141
|
) -> Diagnostic {
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
var
|
|
145
|
-
|
|
142
|
+
// The token after the attribute takes over the trivia of the attribute: the line break,
|
|
143
|
+
// indentation and comments before it, and the comments after it. Its own leading whitespace is
|
|
144
|
+
// left out unless a comment comes before it, so `@Field var size` and `@Field` on its own line both
|
|
145
|
+
// become `var size` on the line of the attribute, and `@Field // note` keeps the line break after
|
|
146
|
+
// the comment.
|
|
147
|
+
var newDecl = varDecl
|
|
148
|
+
if let next = attribute.lastToken(viewMode: .sourceAccurate)?.nextToken(viewMode: .sourceAccurate) {
|
|
149
|
+
let comments = Trivia(pieces: attribute.trailingTrivia.pieces.drop(while: \.isWhitespace))
|
|
150
|
+
let nextTrivia =
|
|
151
|
+
comments.isEmpty ? Trivia(pieces: next.leadingTrivia.pieces.drop(while: \.isWhitespace)) : next.leadingTrivia
|
|
152
|
+
newDecl =
|
|
153
|
+
LeadingTriviaRewriter(token: next.id, trivia: attribute.leadingTrivia + comments + nextTrivia)
|
|
154
|
+
.rewrite(varDecl).cast(VariableDeclSyntax.self)
|
|
155
|
+
}
|
|
156
|
+
// Then remove the attribute with its trivia, so it doesn't leave a blank line behind. The rewritten
|
|
157
|
+
// declaration is a new tree, so the attribute is found by its place in the list.
|
|
158
|
+
if let index = varDecl.attributes.firstIndex(where: { element in
|
|
146
159
|
guard case .attribute(let candidate) = element else {
|
|
147
160
|
return false
|
|
148
161
|
}
|
|
149
162
|
return candidate == attribute
|
|
150
163
|
}) {
|
|
151
|
-
attributes.
|
|
164
|
+
let offset = varDecl.attributes.distance(from: varDecl.attributes.startIndex, to: index)
|
|
165
|
+
newDecl.attributes.remove(at: newDecl.attributes.index(newDecl.attributes.startIndex, offsetBy: offset))
|
|
152
166
|
}
|
|
153
167
|
let fixIt = FixIt(
|
|
154
168
|
message: ViewPropsFixItMessage("Remove the '@Field' attribute", id: "viewprops-remove-field"),
|
|
155
169
|
changes: [
|
|
156
170
|
.replace(
|
|
157
171
|
oldNode: Syntax(varDecl),
|
|
158
|
-
newNode: Syntax(
|
|
172
|
+
newNode: Syntax(newDecl)
|
|
159
173
|
)
|
|
160
174
|
]
|
|
161
175
|
)
|
|
@@ -166,6 +180,22 @@ private func fieldAttributeDiagnostic(
|
|
|
166
180
|
return Diagnostic(node: attribute, message: message, fixIts: [fixIt])
|
|
167
181
|
}
|
|
168
182
|
|
|
183
|
+
/// Replaces the leading trivia of one token.
|
|
184
|
+
private final class LeadingTriviaRewriter: SyntaxRewriter {
|
|
185
|
+
private let token: SyntaxIdentifier
|
|
186
|
+
private let trivia: Trivia
|
|
187
|
+
|
|
188
|
+
init(token: SyntaxIdentifier, trivia: Trivia) {
|
|
189
|
+
self.token = token
|
|
190
|
+
self.trivia = trivia
|
|
191
|
+
super.init(viewMode: .sourceAccurate)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
override func visit(_ token: TokenSyntax) -> TokenSyntax {
|
|
195
|
+
token.id == self.token ? token.with(\.leadingTrivia, trivia) : token
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
169
199
|
/// The error for an optional event prop, attached to the declared type and carrying a fix-it that
|
|
170
200
|
/// unwraps it. Events are registered once at view creation from a static name list, so there is no
|
|
171
201
|
/// shape that could express "sometimes emitted".
|