expo-modules-macros 0.30.0 → 0.31.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/README.md +5 -3
- package/apple/ExpoModulesMacros +0 -0
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/ExpandReport.swift +3 -2
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/MacroExpander.swift +87 -38
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/RunExpand.swift +27 -9
- package/apple/Sources/ExpoModulesCLI/Commands/Fix/RunFix.swift +88 -26
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ExportedSurface.swift +14 -7
- package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/SurfaceVisitor.swift +6 -4
- package/apple/Sources/ExpoModulesMacrosCore/Macros/EventMacro.swift +92 -15
- package/apple/Sources/ExpoModulesMacrosCore/Macros/ExpoModuleMacro.swift +1 -4
- package/apple/Sources/ExpoModulesMacrosCore/Macros/SharedObjectMacro.swift +1 -4
- package/apple/Sources/ExpoModulesMacrosCore/Macros/ViewPropsMacro.swift +24 -10
- package/apple/Sources/ExpoModulesMacrosCore/Support/MacroHelpers.swift +2 -16
- package/build/types.d.ts +3 -3
- package/build/types.js +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ This package lists them as optional dependencies, and their `os`, `cpu` and (on
|
|
|
26
26
|
|
|
27
27
|
- **`@ExpoModule(_ name: String? = nil, classes: [Any.Type] = [])`** on a class. Turns the class into a module: binds its `@JS` members into the module's JavaScript object and resolves the module name from the argument, falling back to the class name. It also synthesizes everything inheriting from `Module` used to provide, so a module class can carry any superclass, or none. It adds an `@unchecked Sendable` conformance when the class isn't `Sendable` yet (see [Sendable conformance](#sendable-conformance)).
|
|
28
28
|
- **`@JS(_ jsName: String? = nil, _ options: JSOptions...)`** on a member of a module or shared object. Marks a function, property or initializer for export. `@ExpoModule` and `@SharedObject` bind each marked member straight into the JavaScript object, with the argument decoding, the call and the result encoding inlined, so there is no dynamic per-call path. The JS name defaults to the Swift name; pass a string to override it. Two `@JS` members with the same JS name on one JavaScript object are a compile-time error, since JavaScript could reach only one of them. A shared object's static and instance members bind onto different objects, so they can share a name. The macro also checks that every type crossing the boundary is convertible in the direction it travels. Outside a module or shared object, a bad type is reported on the author's own declaration. In an `@ExpoModule` or `@SharedObject` class, the class macro makes the check, since only it sees the type aliases in the class body, so the error is reported on the class and names the member and its argument labels, such as `local function 'move(_:by:returning:)' requires that 'Point' conform to 'JavaScriptEncodable'`. A `lazy var` is read-only from JavaScript. On the first read, its getter stores the value as a data property on the JavaScript object, so later reads don't call into native code. On a frozen object the value isn't stored, and each read calls the getter. The `.concurrent` option moves an `async` body off the JavaScript thread while still decoding and encoding on it. The `.withAsync("name")` option binds a synchronous function a second time under that name, as a function that returns a promise and runs the body on the concurrent thread pool. That function isn't isolated to the JavaScript thread, so its body must be safe to run on any thread. Because of that, a `.concurrent` function and a function with `.withAsync` can't take a JavaScript value such as `JavaScriptValue` or `JavaScriptObject`, which is valid only on the JavaScript thread; the macro reports such a parameter. The macro reports a function isolated to a global actor through its own attribute or its type's. When the isolation comes from elsewhere, such as a protocol, the error points into the generated code, so mark the function `nonisolated`. A function or initializer can take a closure argument, such as `(Point) throws -> Size` or `(String) async throws -> Data`. JavaScript passes a function for it, and native code can store the closure and call it from any thread. A closure that throws or returns a value blocks the caller until JavaScript returns, and an `async` closure suspends instead and awaits a promise that the function returns. A closure that returns a value must be `throws`, so that an error from JavaScript can reach the caller. A non-throwing `Void` closure returns before JavaScript reads its arguments, so don't change a class instance after passing it to one.
|
|
29
|
-
- **`@Event(_ name: String? = nil, _ options: EventOptions...)`** on a function-typed `var`. Expands the property into a closure that emits the event, so calling the property sends it to JavaScript with the closure's parameter as the payload. The JS name defaults to the property name with a leading `on` stripped. The `.
|
|
29
|
+
- **`@Event(_ name: String? = nil, _ options: EventOptions...)`** on a function-typed `var`. Expands the property into a closure that emits the event, so calling the property sends it to JavaScript with the closure's parameter as the payload. The JS name defaults to the property name with a leading `on` stripped. By default the event is scheduled on the JavaScript thread. The `.inline` option runs the listeners before the call returns when it is made on the JavaScript thread, and schedules the event otherwise; it never blocks. The `.blocking` option runs the listeners before the call returns on any thread, so a call from another thread blocks until the JavaScript thread runs them. An event property is callable from any thread with each option.
|
|
30
30
|
- **`@SharedObject(_ name: String? = nil)`** on a subclass of `SharedObject`, direct or not, such as a `SharedRef<T>` subclass. Binds the class's `@JS` members through the `SharedObject` hooks that core calls when it builds the JavaScript class, and emits the JS class name, which defaults to the class name. A module exposes the class through `@ExpoModule(classes:)`, which only accepts `SharedObject` subclasses. A subclass of another `@SharedObject` class also gets the `@JS` members of that class, and its own members replace inherited members with the same JS name. A subclass that isn't `final` can't be constructed from JavaScript through the `@JS init` of its superclass, so give each subclass that JavaScript constructs its own `@JS init`. Like `@ExpoModule`, it adds an `@unchecked Sendable` conformance when the class isn't `Sendable` yet.
|
|
31
31
|
- **`@Record()`** on a struct, or on a final class with no inheritance clause (declare a class's protocol conformances in an extension). Treats every non-static, non-private, non-computed stored property as a field, with no per-field wrapper, and synthesizes the memberwise initializer, the conversions in both directions, and the `Record` conformance. A record converts only from and to JavaScript values: its `from(dictionary:appContext:)` throws `RecordDictionaryConversionException`, so a record can't be created from a native `[String: Any]`. A `@Union` can be a record property. Requiredness is inferred: a default value makes a field optional, an optional type makes it nullable and optional.
|
|
32
32
|
- **`@Union()`** on an enum whose cases each carry one associated value. Models a TypeScript union. Synthesizes the conversions in both directions, a typed accessor per payload type, and the `JavaScriptDecodable` and `JavaScriptEncodable` conformances. Decoding is ordered: the first case whose payload decodes wins, so the more specific case goes first.
|
|
@@ -145,11 +145,13 @@ Each applied Fix-It is printed to stdout as `<file>:<line>:<column>: fix-it: <me
|
|
|
145
145
|
|
|
146
146
|
`fix` applies the Fix-Its of errors and of warnings, such as "Make the class 'public'" for a module class that isn't `public`. With `--only-errors`, it applies only those of errors.
|
|
147
147
|
|
|
148
|
-
- A macro can stop at its first error, so after applying the Fix-Its of a file, `fix` expands it again and applies the new ones, until none applies (at most 10 passes). The location of a Fix-It is in the file as it was when the Fix-It was applied.
|
|
149
|
-
- A diagnostic with more than one Fix-It is skipped, because they are alternatives. So is a Fix-It whose edits overlap the edits of another one, or whose text isn't in the file where the edit expects it. Each skipped Fix-It is printed to stderr as a warning.
|
|
148
|
+
- A macro can stop at its first error, so after applying the Fix-Its of a file, `fix` expands it again and applies the new ones, until none applies (at most 10 passes, with a warning when Fix-Its are left after them). The location of a Fix-It is in the file as it was when the Fix-It was applied.
|
|
149
|
+
- A diagnostic with more than one Fix-It is skipped, because they are alternatives. So is a Fix-It whose edits overlap the edits of another one, or whose text isn't in the file where the edit expects it; an insertion is checked against the few bytes after it. Each skipped Fix-It is printed to stderr as a warning.
|
|
150
150
|
- It applies only the Fix-Its of the macros, not those of syntax errors.
|
|
151
151
|
- It changes only the text that a Fix-It changes and doesn't format the file. An inserted keyword can make a line longer than your formatter allows, so run your formatter after `fix` if you use one.
|
|
152
152
|
|
|
153
|
+
`fix` writes a file only when it still has the contents that `fix` read, so it doesn't overwrite a change saved meanwhile. It writes the file in place, so on macOS and Linux, symbolic and hard links, permissions, the owner, extended attributes and ACLs stay, and a read-only file isn't written. When a write fails, `fix` writes the original contents back and reports whether the file is unchanged; when that fails too, it saves them to a file in the temporary directory and names it. A file that can't be written is reported, and the other files are still fixed. A UTF-8 byte order mark is kept.
|
|
154
|
+
|
|
153
155
|
The exit code is `0` also when a Fix-It is skipped or errors remain, `1` when a file can't be written, and `2` for an input that `expand` doesn't take.
|
|
154
156
|
|
|
155
157
|
# TypeScript wrapper
|
package/apple/ExpoModulesMacros
CHANGED
|
Binary file
|
|
@@ -107,8 +107,9 @@ public struct ExpansionEdit: Equatable, Sendable {
|
|
|
107
107
|
/// The UTF-8 offset in the original file.
|
|
108
108
|
public let offset: Int
|
|
109
109
|
|
|
110
|
-
/// The text that the edit replaces, so `fix` can check that the file still has it there.
|
|
111
|
-
///
|
|
110
|
+
/// The text that the edit replaces, so `fix` can check that the file still has it there. An
|
|
111
|
+
/// insertion also replaces a few bytes after it with themselves, so it has text to check too, and
|
|
112
|
+
/// is empty only at the end of a declaration.
|
|
112
113
|
public let original: String
|
|
113
114
|
|
|
114
115
|
public let replacement: String
|
|
@@ -35,8 +35,10 @@ public func expandMacros(source: String, file: String, symbols: [String]) -> Exp
|
|
|
35
35
|
let originalDeclarations = OriginalDeclarations(tree)
|
|
36
36
|
diagnostics += context.diagnostics.map { diagnostic in
|
|
37
37
|
let location = { (position: AbsolutePosition, node: Syntax) -> SourceLocation in
|
|
38
|
-
let
|
|
39
|
-
|
|
38
|
+
let original = originalDeclarations.position(of: position, in: node) {
|
|
39
|
+
context.location(for: node.root.position, anchoredAt: node, fileName: file).offset
|
|
40
|
+
}
|
|
41
|
+
if let original {
|
|
40
42
|
return converter.location(for: original)
|
|
41
43
|
}
|
|
42
44
|
return context.location(for: position, anchoredAt: node, fileName: file)
|
|
@@ -221,13 +223,21 @@ private func originalEdits(
|
|
|
221
223
|
of fixIt: FixIt,
|
|
222
224
|
offset: (AbsolutePosition, Syntax) -> Int
|
|
223
225
|
) -> [ExpansionEdit]? {
|
|
224
|
-
|
|
226
|
+
// The edit of each change in the tree of its node. `FixIt.edits` drops an edit that overlaps an
|
|
227
|
+
// earlier one; this applies the same rule.
|
|
228
|
+
var changes: [(edit: SourceEdit, node: Syntax)] = []
|
|
225
229
|
for change in fixIt.changes {
|
|
226
|
-
// `FixIt.edits` drops an edit that overlaps an earlier one; this applies the same rule.
|
|
227
230
|
guard let edit = FixIt(message: fixIt.message, changes: [change]).edits.first else {
|
|
228
231
|
continue
|
|
229
232
|
}
|
|
230
233
|
let node = changedNode(change)
|
|
234
|
+
if !changes.contains(where: { $0.node.root.id == node.root.id && $0.edit.range.overlaps(edit.range) }) {
|
|
235
|
+
changes.append((edit, node))
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
var edits: [ExpansionEdit] = []
|
|
240
|
+
for (index, (edit, node)) in changes.enumerated() {
|
|
231
241
|
let root = node.root
|
|
232
242
|
let text = Array(root.description.utf8)
|
|
233
243
|
let start = edit.range.lowerBound.utf8Offset - root.position.utf8Offset
|
|
@@ -241,19 +251,36 @@ private func originalEdits(
|
|
|
241
251
|
guard prefix + suffix < max(old.count, new.count) else {
|
|
242
252
|
continue
|
|
243
253
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
254
|
+
var original = Array(old.dropFirst(prefix).dropLast(suffix))
|
|
255
|
+
var replacement = Array(new.dropFirst(prefix).dropLast(suffix))
|
|
256
|
+
if original.isEmpty {
|
|
257
|
+
// An insertion has no text that `fix` can check in the file, so it also replaces the bytes after
|
|
258
|
+
// it with themselves, up to a character boundary and not into another change of the Fix-It.
|
|
259
|
+
let insertion = start + prefix
|
|
260
|
+
let nextChange = changes.enumerated().filter { $0.offset != index && $0.element.node.root.id == root.id }
|
|
261
|
+
.map { $0.element.edit.range.lowerBound.utf8Offset - root.position.utf8Offset }
|
|
262
|
+
.filter { $0 >= insertion }.min()
|
|
263
|
+
var contextEnd = min(insertion + insertionContextLength, text.count, nextChange ?? text.count)
|
|
264
|
+
while contextEnd > insertion && contextEnd < text.count && text[contextEnd] & 0xC0 == 0x80 {
|
|
265
|
+
contextEnd -= 1
|
|
266
|
+
}
|
|
267
|
+
original = Array(text[insertion..<contextEnd])
|
|
268
|
+
replacement += original
|
|
252
269
|
}
|
|
270
|
+
edits.append(
|
|
271
|
+
ExpansionEdit(
|
|
272
|
+
offset: offset(edit.range.lowerBound.advanced(by: prefix), node),
|
|
273
|
+
original: String(decoding: original, as: UTF8.self),
|
|
274
|
+
replacement: String(decoding: replacement, as: UTF8.self)
|
|
275
|
+
)
|
|
276
|
+
)
|
|
253
277
|
}
|
|
254
278
|
return edits
|
|
255
279
|
}
|
|
256
280
|
|
|
281
|
+
/// The number of bytes after an insertion that its edit also replaces with themselves.
|
|
282
|
+
private let insertionContextLength = 8
|
|
283
|
+
|
|
257
284
|
/// The node in the tree that `change` edits.
|
|
258
285
|
private func changedNode(_ change: FixIt.Change) -> Syntax {
|
|
259
286
|
switch change {
|
|
@@ -268,11 +295,6 @@ private func changedNode(_ change: FixIt.Change) -> Syntax {
|
|
|
268
295
|
}
|
|
269
296
|
}
|
|
270
297
|
|
|
271
|
-
/// Whether two edits change the same bytes of the original file.
|
|
272
|
-
private func overlaps(_ a: ExpansionEdit, _ b: ExpansionEdit) -> Bool {
|
|
273
|
-
a.offset < b.offset + b.original.utf8.count && b.offset < a.offset + a.original.utf8.count
|
|
274
|
-
}
|
|
275
|
-
|
|
276
298
|
/// The lengths of the longest common prefix and suffix of `old` and `new` that don't overlap, at
|
|
277
299
|
/// character boundaries, so the bytes between them are valid UTF-8 on both sides.
|
|
278
300
|
private func commonAffixLengths(_ old: [UInt8], _ new: [UInt8]) -> (prefix: Int, suffix: Int) {
|
|
@@ -320,45 +342,72 @@ private func severity(_ severity: DiagnosticSeverity) -> ExpansionDiagnostic.Sev
|
|
|
320
342
|
/// `init`) to its end is the same in both, so this moves the position by the offset of the keyword
|
|
321
343
|
/// in the original declaration. A position before the keyword, in the attributes or the modifiers,
|
|
322
344
|
/// isn't mapped.
|
|
323
|
-
|
|
345
|
+
///
|
|
346
|
+
/// An accessor macro can get a declaration that swift-syntax rewrote rather than detached, for
|
|
347
|
+
/// example an `@Event` property with a closure in its initializer, and its start doesn't map to the
|
|
348
|
+
/// file. Such a declaration is found by its text when no other declaration in the file has it.
|
|
349
|
+
private final class OriginalDeclarations {
|
|
324
350
|
/// The declarations in the original file by the UTF-8 offset of their start, with their leading
|
|
325
351
|
/// trivia: the text from the keyword to the end of each, and the position of its keyword.
|
|
326
|
-
private let
|
|
352
|
+
private let declarationsByStart: [Int: [(text: String, keyword: AbsolutePosition)]]
|
|
353
|
+
|
|
354
|
+
/// The positions of the keywords of the declarations in the original file, by the text from the
|
|
355
|
+
/// keyword to the end of the declaration.
|
|
356
|
+
private let keywordsByText: [String: [AbsolutePosition]]
|
|
357
|
+
|
|
358
|
+
/// The positions of the keyword of each declaration that a macro got and of its original
|
|
359
|
+
/// declaration, or `nil` when it has no original declaration.
|
|
360
|
+
private var keywords: [SyntaxIdentifier: (keyword: AbsolutePosition, original: AbsolutePosition)?] = [:]
|
|
327
361
|
|
|
328
362
|
init(_ tree: SourceFileSyntax) {
|
|
329
363
|
let collector = DeclarationCollector(viewMode: .sourceAccurate)
|
|
330
364
|
collector.walk(tree)
|
|
331
|
-
var
|
|
365
|
+
var declarationsByStart: [Int: [(text: String, keyword: AbsolutePosition)]] = [:]
|
|
366
|
+
var keywordsByText: [String: [AbsolutePosition]] = [:]
|
|
332
367
|
for declaration in collector.declarations {
|
|
333
368
|
guard let keyword = keywordToken(of: declaration) else {
|
|
334
369
|
continue
|
|
335
370
|
}
|
|
336
|
-
|
|
337
|
-
|
|
371
|
+
let text = textFromKeyword(keyword, of: declaration)
|
|
372
|
+
declarationsByStart[declaration.position.utf8Offset, default: []].append(
|
|
373
|
+
(text, keyword.positionAfterSkippingLeadingTrivia)
|
|
338
374
|
)
|
|
375
|
+
keywordsByText[text, default: []].append(keyword.positionAfterSkippingLeadingTrivia)
|
|
339
376
|
}
|
|
340
|
-
self.
|
|
377
|
+
self.declarationsByStart = declarationsByStart
|
|
378
|
+
self.keywordsByText = keywordsByText
|
|
341
379
|
}
|
|
342
380
|
|
|
343
381
|
/// The position in the original file of `position` in `node`, or `nil` when `node` is in the
|
|
344
|
-
/// original file, isn't in a
|
|
345
|
-
///
|
|
382
|
+
/// original file, isn't in a declaration that a macro got, is before its keyword, or its original
|
|
383
|
+
/// declaration isn't found.
|
|
346
384
|
///
|
|
347
|
-
/// `declarationStart`
|
|
348
|
-
///
|
|
349
|
-
func position(
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
385
|
+
/// `declarationStart` returns the offset that the macro system maps the start of the declaration
|
|
386
|
+
/// to. It is called once for each declaration.
|
|
387
|
+
func position(
|
|
388
|
+
of position: AbsolutePosition,
|
|
389
|
+
in node: Syntax,
|
|
390
|
+
declarationStart: () -> Int
|
|
391
|
+
) -> AbsolutePosition? {
|
|
392
|
+
guard !node.root.is(SourceFileSyntax.self), let declaration = node.root.as(DeclSyntax.self) else {
|
|
393
|
+
return nil
|
|
394
|
+
}
|
|
395
|
+
let found: (keyword: AbsolutePosition, original: AbsolutePosition)?
|
|
396
|
+
if let cached = keywords[declaration.id] {
|
|
397
|
+
found = cached
|
|
398
|
+
} else {
|
|
399
|
+
found = keywordToken(of: declaration).flatMap { keyword in
|
|
400
|
+
let text = textFromKeyword(keyword, of: declaration)
|
|
401
|
+
let byStart = declarationsByStart[declarationStart()]?.first { $0.text == text }?.keyword
|
|
402
|
+
let byText = keywordsByText[text].flatMap { $0.count == 1 ? $0.first : nil }
|
|
403
|
+
return (byStart ?? byText).map { (keyword.positionAfterSkippingLeadingTrivia, $0) }
|
|
404
|
+
}
|
|
405
|
+
keywords[declaration.id] = found
|
|
406
|
+
}
|
|
407
|
+
guard let found, position >= found.keyword else {
|
|
358
408
|
return nil
|
|
359
409
|
}
|
|
360
|
-
|
|
361
|
-
return AbsolutePosition(utf8Offset: original.keyword.utf8Offset + offset)
|
|
410
|
+
return AbsolutePosition(utf8Offset: found.original.utf8Offset + position.utf8Offset - found.keyword.utf8Offset)
|
|
362
411
|
}
|
|
363
412
|
}
|
|
364
413
|
|
|
@@ -86,14 +86,14 @@ func expandFiles(options: ExpandOptions, expander: MacroExpander) -> Result<Expa
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
/// The files of `options` with their sources, expanded with `expander`,
|
|
90
|
-
/// `fix` uses it too, because it edits the sources.
|
|
89
|
+
/// The files of `options` with their sources and the bytes that were read, expanded with `expander`,
|
|
90
|
+
/// as `expandFiles` describes. `fix` uses it too, because it edits the sources.
|
|
91
91
|
func expandSources(
|
|
92
92
|
options: ExpandOptions,
|
|
93
93
|
expander: MacroExpander
|
|
94
|
-
) -> Result<[(source: String, expanded: ExpandedFile)], ExpandInputError> {
|
|
94
|
+
) -> Result<[(source: String, contents: Data, expanded: ExpandedFile)], ExpandInputError> {
|
|
95
95
|
let prefilter = MacroPrefilter(attributeNames: expander.attributeNames)
|
|
96
|
-
var sources: [(file: String, source: String)] = []
|
|
96
|
+
var sources: [(file: String, source: String, contents: Data)] = []
|
|
97
97
|
var seen: Set<String> = []
|
|
98
98
|
for path in options.paths {
|
|
99
99
|
switch isDirectory(atPath: path) {
|
|
@@ -101,12 +101,12 @@ func expandSources(
|
|
|
101
101
|
return .failure(ExpandInputError(message: "no such file or directory '\(path)'"))
|
|
102
102
|
case .some(true):
|
|
103
103
|
for file in swiftFiles(in: [path]) where !seen.contains(fileIdentity(file)) {
|
|
104
|
-
guard let source =
|
|
104
|
+
guard let (source, contents) = readSource(atPath: file) else {
|
|
105
105
|
return .failure(ExpandInputError(message: "could not read '\(file)'"))
|
|
106
106
|
}
|
|
107
107
|
seen.insert(fileIdentity(file))
|
|
108
108
|
if mightContainMacro(in: source, prefilter: prefilter) {
|
|
109
|
-
sources.append((file, source))
|
|
109
|
+
sources.append((file, source, contents))
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
case .some(false):
|
|
@@ -116,15 +116,17 @@ func expandSources(
|
|
|
116
116
|
guard !seen.contains(fileIdentity(path)) else {
|
|
117
117
|
continue
|
|
118
118
|
}
|
|
119
|
-
guard let source =
|
|
119
|
+
guard let (source, contents) = readSource(atPath: path) else {
|
|
120
120
|
return .failure(ExpandInputError(message: "could not read '\(path)'"))
|
|
121
121
|
}
|
|
122
122
|
seen.insert(fileIdentity(path))
|
|
123
|
-
sources.append((path, source))
|
|
123
|
+
sources.append((path, source, contents))
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
let files = sources.map {
|
|
127
|
+
let files = sources.map {
|
|
128
|
+
(source: $0.source, contents: $0.contents, expanded: expander.expand($0.source, $0.file, options.symbols))
|
|
129
|
+
}
|
|
128
130
|
|
|
129
131
|
let found = Set(files.flatMap { $0.expanded.expansions.compactMap(\.symbol) })
|
|
130
132
|
if let missing = options.symbols.first(where: { !found.contains($0) }) {
|
|
@@ -139,6 +141,22 @@ func expandSources(
|
|
|
139
141
|
return .success(files)
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
/// The bytes of a UTF-8 byte order mark.
|
|
145
|
+
let utf8ByteOrderMark: [UInt8] = [0xEF, 0xBB, 0xBF]
|
|
146
|
+
|
|
147
|
+
/// The contents of the file at `path` as text, without a UTF-8 byte order mark, and the bytes that
|
|
148
|
+
/// were read. `nil` when the file can't be read or isn't UTF-8.
|
|
149
|
+
func readSource(atPath path: String) -> (source: String, contents: Data)? {
|
|
150
|
+
guard let contents = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
|
|
151
|
+
return nil
|
|
152
|
+
}
|
|
153
|
+
let text = contents.starts(with: utf8ByteOrderMark) ? contents.dropFirst(utf8ByteOrderMark.count) : contents[...]
|
|
154
|
+
guard let source = String(bytes: text, encoding: .utf8) else {
|
|
155
|
+
return nil
|
|
156
|
+
}
|
|
157
|
+
return (source, contents)
|
|
158
|
+
}
|
|
159
|
+
|
|
142
160
|
/// The expanded sources as text. A single expansion is printed as it is; more than one each get a
|
|
143
161
|
/// `// <file>` or `// <file>: <symbol>` header and are separated by an empty line.
|
|
144
162
|
func renderExpansions(_ files: [ExpandedFile]) -> String {
|
|
@@ -28,8 +28,7 @@ struct FixedSource: Equatable {
|
|
|
28
28
|
let source: String
|
|
29
29
|
let fixIts: [FixItReport]
|
|
30
30
|
|
|
31
|
-
/// Whether `fixSource` stopped after `maxFixPasses` passes while
|
|
32
|
-
/// Fix-It, so the source can have more.
|
|
31
|
+
/// Whether `fixSource` stopped after `maxFixPasses` passes while Fix-Its were left to apply.
|
|
33
32
|
var reachedPassLimit = false
|
|
34
33
|
|
|
35
34
|
var applied: [FixItReport] {
|
|
@@ -46,8 +45,16 @@ extension Scanner {
|
|
|
46
45
|
/// `--dry-run`, the files aren't written. A file that can't be written is reported and the others are
|
|
47
46
|
/// still fixed. The exit code is `0` also when a Fix-It is skipped, `1` when a file can't be written,
|
|
48
47
|
/// and `2` for an input that `expand` doesn't take.
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
///
|
|
49
|
+
/// `write` writes a fixed file and returns whether it changed it (see `writeFixedSource`); tests
|
|
50
|
+
/// replace it. A file that another path already fixed, through a hard link, isn't printed or counted
|
|
51
|
+
/// again.
|
|
52
|
+
static func runFix(
|
|
53
|
+
options: FixOptions,
|
|
54
|
+
expander: MacroExpander,
|
|
55
|
+
write: (_ source: String, _ path: String, _ original: Data) throws -> Bool = writeFixedSource
|
|
56
|
+
) -> Int32 {
|
|
57
|
+
let sources: [(source: String, contents: Data, expanded: ExpandedFile)]
|
|
51
58
|
switch expandSources(options: ExpandOptions(paths: options.paths, symbols: options.symbols), expander: expander) {
|
|
52
59
|
case .success(let expanded):
|
|
53
60
|
sources = expanded
|
|
@@ -58,8 +65,8 @@ extension Scanner {
|
|
|
58
65
|
|
|
59
66
|
var appliedCount = 0
|
|
60
67
|
var fixedFiles = 0
|
|
61
|
-
var
|
|
62
|
-
for (source, expanded) in sources {
|
|
68
|
+
var failedFiles = 0
|
|
69
|
+
for (source, contents, expanded) in sources {
|
|
63
70
|
let fixed = fixSource(
|
|
64
71
|
source,
|
|
65
72
|
expanded: expanded,
|
|
@@ -73,10 +80,12 @@ extension Scanner {
|
|
|
73
80
|
}
|
|
74
81
|
if !options.dryRun {
|
|
75
82
|
do {
|
|
76
|
-
try
|
|
83
|
+
guard try write(fixed.source, expanded.file, contents) else {
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
77
86
|
} catch {
|
|
78
87
|
writeToStandardError("error: could not write '\(expanded.file)': \(error)\n")
|
|
79
|
-
|
|
88
|
+
failedFiles += 1
|
|
80
89
|
continue
|
|
81
90
|
}
|
|
82
91
|
}
|
|
@@ -85,20 +94,62 @@ extension Scanner {
|
|
|
85
94
|
fixedFiles += 1
|
|
86
95
|
}
|
|
87
96
|
|
|
88
|
-
writeToStandardOutput(
|
|
89
|
-
|
|
97
|
+
writeToStandardOutput(
|
|
98
|
+
renderFixSummary(applied: appliedCount, files: fixedFiles, failed: failedFiles, dryRun: options.dryRun)
|
|
99
|
+
)
|
|
100
|
+
return failedFiles > 0 ? 1 : 0
|
|
90
101
|
}
|
|
91
102
|
}
|
|
92
103
|
|
|
93
|
-
///
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
104
|
+
/// Why `writeFixedSource` didn't write a file.
|
|
105
|
+
struct FixWriteError: Error, CustomStringConvertible {
|
|
106
|
+
let description: String
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Writes `source`, the fixed source of the file at `path`, whose contents were `original`, and
|
|
110
|
+
/// returns whether it changed the file.
|
|
111
|
+
///
|
|
112
|
+
/// The file must still have the contents that `fix` read, so a change that an editor saved meanwhile
|
|
113
|
+
/// isn't lost. A file that already has the fixed contents, because `fix` wrote it through another
|
|
114
|
+
/// hard link, is left as it is. It keeps the UTF-8 byte order mark of the file, which `readSource`
|
|
115
|
+
/// removes from the source.
|
|
116
|
+
///
|
|
117
|
+
/// The file is written in place, so on macOS and Linux its links, permissions, owner, extended
|
|
118
|
+
/// attributes and ACLs stay, and a read-only file isn't written. When the write fails, the original contents are written back,
|
|
119
|
+
/// and the error tells whether the file is unchanged. When that fails too, the original contents are
|
|
120
|
+
/// saved to a file in the temporary directory, which the error names.
|
|
121
|
+
@discardableResult
|
|
122
|
+
func writeFixedSource(_ source: String, toFile path: String, original: Data) throws -> Bool {
|
|
98
123
|
let url = URL(fileURLWithPath: path)
|
|
99
|
-
let
|
|
100
|
-
let
|
|
101
|
-
|
|
124
|
+
let contents = Data((original.starts(with: utf8ByteOrderMark) ? utf8ByteOrderMark : []) + Array(source.utf8))
|
|
125
|
+
guard let current = try? Data(contentsOf: url) else {
|
|
126
|
+
throw FixWriteError(description: "the file can't be read anymore")
|
|
127
|
+
}
|
|
128
|
+
guard current != contents else {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
guard current == original else {
|
|
132
|
+
throw FixWriteError(description: "the file changed after fix read it")
|
|
133
|
+
}
|
|
134
|
+
do {
|
|
135
|
+
try contents.write(to: url)
|
|
136
|
+
} catch {
|
|
137
|
+
if (try? Data(contentsOf: url)) != original {
|
|
138
|
+
try? original.write(to: url)
|
|
139
|
+
}
|
|
140
|
+
if (try? Data(contentsOf: url)) == original {
|
|
141
|
+
throw FixWriteError(description: "\(error); the file is unchanged")
|
|
142
|
+
}
|
|
143
|
+
let backup = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
144
|
+
"\(url.lastPathComponent).\(UUID().uuidString).original"
|
|
145
|
+
)
|
|
146
|
+
let saved = (try? original.write(to: backup)) != nil
|
|
147
|
+
throw FixWriteError(
|
|
148
|
+
description: "\(error); the file can be incomplete"
|
|
149
|
+
+ (saved ? ", and its original contents are in '\(backup.path)'" : "")
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
return true
|
|
102
153
|
}
|
|
103
154
|
|
|
104
155
|
/// The most times that `fixSource` expands a file again after applying Fix-Its.
|
|
@@ -126,10 +177,17 @@ func fixSource(
|
|
|
126
177
|
fixed = applyFixIts(to: fixed.source, diagnostics: selected(diagnostics))
|
|
127
178
|
applied += fixed.applied
|
|
128
179
|
}
|
|
180
|
+
// After the last pass, expand once more to tell whether Fix-Its are left.
|
|
181
|
+
let reachedPassLimit =
|
|
182
|
+
!fixed.applied.isEmpty
|
|
183
|
+
&& !applyFixIts(
|
|
184
|
+
to: fixed.source,
|
|
185
|
+
diagnostics: selected(expander.expand(fixed.source, expanded.file, symbols).diagnostics)
|
|
186
|
+
).applied.isEmpty
|
|
129
187
|
return FixedSource(
|
|
130
188
|
source: fixed.source,
|
|
131
189
|
fixIts: applied + fixed.fixIts.filter { $0.skipReason != nil },
|
|
132
|
-
reachedPassLimit:
|
|
190
|
+
reachedPassLimit: reachedPassLimit
|
|
133
191
|
)
|
|
134
192
|
}
|
|
135
193
|
|
|
@@ -217,12 +275,16 @@ func renderSkippedFixIts(_ fixed: FixedSource, file: String) -> String {
|
|
|
217
275
|
return text
|
|
218
276
|
}
|
|
219
277
|
|
|
220
|
-
/// The summary line of `fix`, for example `Applied 3 Fix-Its in 2 files.`
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
278
|
+
/// The summary line of `fix`, for example `Applied 3 Fix-Its in 2 files.` or, when files couldn't be
|
|
279
|
+
/// written, `Applied 1 Fix-It in 1 file. Could not write 1 file.`
|
|
280
|
+
func renderFixSummary(applied: Int, files: Int, failed: Int = 0, dryRun: Bool) -> String {
|
|
281
|
+
let count = { (n: Int, noun: String) in "\(n) \(noun)\(n == 1 ? "" : "s")" }
|
|
282
|
+
var parts: [String] = []
|
|
283
|
+
if applied > 0 {
|
|
284
|
+
parts.append("\(dryRun ? "Would apply" : "Applied") \(count(applied, "Fix-It")) in \(count(files, "file")).")
|
|
285
|
+
}
|
|
286
|
+
if failed > 0 {
|
|
287
|
+
parts.append("Could not write \(count(failed, "file")).")
|
|
224
288
|
}
|
|
225
|
-
|
|
226
|
-
let fileCount = "\(files) file\(files == 1 ? "" : "s")"
|
|
227
|
-
return "\(dryRun ? "Would apply" : "Applied") \(fixIts) in \(fileCount).\n"
|
|
289
|
+
return (parts.isEmpty ? "No Fix-Its to apply." : parts.joined(separator: " ")) + "\n"
|
|
228
290
|
}
|
|
@@ -207,8 +207,8 @@ 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
|
-
///
|
|
211
|
-
let
|
|
210
|
+
/// How the event is dispatched: the default `scheduled`, or the `.inline` or `.blocking` option.
|
|
211
|
+
let dispatch: ExportedEventDispatch
|
|
212
212
|
|
|
213
213
|
/// The spelled access modifier, or `nil` when none is written.
|
|
214
214
|
let accessLevel: String?
|
|
@@ -216,8 +216,7 @@ struct ExportedEvent: Encodable, Equatable {
|
|
|
216
216
|
let platforms: [ExportedPlatform]
|
|
217
217
|
|
|
218
218
|
private enum CodingKeys: String, CodingKey {
|
|
219
|
-
case name, jsName, payload, accessLevel, platforms
|
|
220
|
-
case isSync = "sync"
|
|
219
|
+
case name, jsName, payload, dispatch, accessLevel, platforms
|
|
221
220
|
}
|
|
222
221
|
|
|
223
222
|
func encode(to encoder: Encoder) throws {
|
|
@@ -225,7 +224,7 @@ struct ExportedEvent: Encodable, Equatable {
|
|
|
225
224
|
try container.encode(name, forKey: .name)
|
|
226
225
|
try container.encode(jsName, forKey: .jsName)
|
|
227
226
|
try container.encodeIfPresent(payload, forKey: .payload)
|
|
228
|
-
try container.encode(
|
|
227
|
+
try container.encode(dispatch, forKey: .dispatch)
|
|
229
228
|
try container.encodeIfPresent(accessLevel, forKey: .accessLevel)
|
|
230
229
|
try container.encode(platforms, forKey: .platforms)
|
|
231
230
|
}
|
|
@@ -471,6 +470,13 @@ struct ExportedSurface: Encodable, Equatable {
|
|
|
471
470
|
let unions: [ExportedUnion]
|
|
472
471
|
}
|
|
473
472
|
|
|
473
|
+
/// How an `@Event` is dispatched to JavaScript, from its option. Mirrors `EventDispatch` in the macros.
|
|
474
|
+
enum ExportedEventDispatch: String, Encodable, Equatable {
|
|
475
|
+
case scheduled
|
|
476
|
+
case inline
|
|
477
|
+
case blocking
|
|
478
|
+
}
|
|
479
|
+
|
|
474
480
|
/// Version of the `scan-exports` output shape. Bumped on any breaking change to the envelope or to
|
|
475
481
|
/// anything under `exports`, so a consumer can verify it understands the output before trusting it.
|
|
476
482
|
/// Versioned independently of `scanModulesSchemaVersion`: the two commands serve different consumers
|
|
@@ -481,8 +487,9 @@ struct ExportedSurface: Encodable, Equatable {
|
|
|
481
487
|
/// declarations in `#if` blocks, adds `platforms` (with deprecations), `accessLevel`, and
|
|
482
488
|
/// `defaultValue`, and replaces `constructorParameters` with `initializer`; version 9 adds the
|
|
483
489
|
/// `sharedRef` type kind and the `sharedRef` of a shared object; version 10 adds the `tuple` and `object` type kinds (named after a tuple alias)
|
|
484
|
-
/// and resolves the tuple type aliases declared in `@ExpoModule` and `@SharedObject` classes
|
|
485
|
-
|
|
490
|
+
/// and resolves the tuple type aliases declared in `@ExpoModule` and `@SharedObject` classes; version 11
|
|
491
|
+
/// replaces the `sync` flag of an event with `dispatch`.
|
|
492
|
+
let scanExportsSchemaVersion = 11
|
|
486
493
|
|
|
487
494
|
/// The `scan-exports` result: the surface plus the run's stats. A distinct envelope from
|
|
488
495
|
/// `ScanModulesResult` (different consumer: TS generation vs. autolinking).
|
|
@@ -285,7 +285,9 @@ final class SurfaceVisitor: SyntaxVisitor {
|
|
|
285
285
|
return []
|
|
286
286
|
}
|
|
287
287
|
let override = stringArgument(of: attribute)
|
|
288
|
-
let
|
|
288
|
+
let dispatch: ExportedEventDispatch =
|
|
289
|
+
hasEventOption(attribute, named: "inline")
|
|
290
|
+
? .inline : hasEventOption(attribute, named: "blocking") ? .blocking : .scheduled
|
|
289
291
|
var result: [ExportedEvent] = []
|
|
290
292
|
|
|
291
293
|
for binding in varDecl.bindings {
|
|
@@ -306,7 +308,7 @@ final class SurfaceVisitor: SyntaxVisitor {
|
|
|
306
308
|
name: name,
|
|
307
309
|
jsName: override ?? defaultEventName(for: name),
|
|
308
310
|
payload: functionType.parameters.first.map { typeNode(from: $0.type) },
|
|
309
|
-
|
|
311
|
+
dispatch: dispatch,
|
|
310
312
|
accessLevel: spelledAccessLevel(of: varDecl.modifiers),
|
|
311
313
|
platforms: platforms
|
|
312
314
|
)
|
|
@@ -704,8 +706,8 @@ private func isVoidEventReturn(_ type: TypeSyntax) -> Bool {
|
|
|
704
706
|
return text == "Void" || text == "()" || text == "Swift.Void"
|
|
705
707
|
}
|
|
706
708
|
|
|
707
|
-
/// True if an `@Event` attribute lists the given option member (`@Event(.
|
|
708
|
-
/// `@Event("name", EventOptions.
|
|
709
|
+
/// True if an `@Event` attribute lists the given option member (`@Event(.inline)` or
|
|
710
|
+
/// `@Event("name", EventOptions.inline)`). Like `hasOption` in the macros, but without the array-literal
|
|
709
711
|
/// branch, which `EventOptions...` doesn't accept.
|
|
710
712
|
private func hasEventOption(_ attribute: AttributeSyntax, named option: String) -> Bool {
|
|
711
713
|
guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else {
|
|
@@ -32,12 +32,12 @@ import SwiftSyntaxMacros
|
|
|
32
32
|
/// also self-contained: `@ExpoModule`/`@SharedObject` neither collect `@Event` members nor register
|
|
33
33
|
/// their names anywhere.
|
|
34
34
|
///
|
|
35
|
-
///
|
|
36
|
-
///
|
|
37
|
-
///
|
|
38
|
-
///
|
|
39
|
-
///
|
|
40
|
-
///
|
|
35
|
+
/// Two options change how the event is dispatched, and each one calls its own `EventEmitter` method,
|
|
36
|
+
/// which is callable from any thread as well:
|
|
37
|
+
/// - `@Event(.inline)` calls `emitInline`: on the JS thread the listeners run before the call
|
|
38
|
+
/// returns; on any other thread the event is scheduled, the same as `emit`. It never blocks.
|
|
39
|
+
/// - `@Event(.blocking)` calls `emitBlocking`: the listeners run before the call returns on any
|
|
40
|
+
/// thread, so a call from another thread blocks until the JS thread runs them.
|
|
41
41
|
///
|
|
42
42
|
/// As a **peer**, the macro emits a never-called conformance assertion (see
|
|
43
43
|
/// `TypeConformanceAssertion.swift`) checking that the payload type is JS-convertible and that the
|
|
@@ -51,11 +51,10 @@ public struct EventMacro: AccessorMacro {
|
|
|
51
51
|
) throws -> [AccessorDeclSyntax] {
|
|
52
52
|
let event = try validatedEvent(of: node, on: declaration)
|
|
53
53
|
// The closure's parameter and return types are inferred from the property's declared type
|
|
54
|
-
// through the getter, so the body never has to spell the payload type.
|
|
55
|
-
// `
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
let emitMethod = event.isSync ? "emitSync" : "emit"
|
|
54
|
+
// through the getter, so the body never has to spell the payload type. The dispatch option
|
|
55
|
+
// picks the `EventEmitter` method. A payload that holds a tuple can't conform to the payload
|
|
56
|
+
// protocols, so it's passed with a closure that encodes it.
|
|
57
|
+
let emitMethod = event.dispatch.emitMethod
|
|
59
58
|
let closure: String
|
|
60
59
|
if let tuple = event.payloadType.flatMap({ tupleConversion(of: $0) }) {
|
|
61
60
|
let encode = encoderClosure(encoding: tuple.encodeCall(of: "value", into: "slot"))
|
|
@@ -96,9 +95,13 @@ extension EventMacro: PeerMacro {
|
|
|
96
95
|
//
|
|
97
96
|
// A payload that holds a tuple is encoded element by element (see `TupleConversion`), so the types
|
|
98
97
|
// in it are asserted `JavaScriptEncodable`, which the encode closure needs, in its place.
|
|
98
|
+
// `emitInline` and `emitBlocking` take only `JavaScriptEncodable` payloads, so with those options
|
|
99
|
+
// the payload is asserted `JavaScriptEncodable` too.
|
|
99
100
|
let name = event.swiftName
|
|
100
101
|
let tupleElementTypes = event.payloadType.flatMap { tupleConversion(of: $0)?.conformingTypes }
|
|
101
|
-
let payloadConstraint =
|
|
102
|
+
let payloadConstraint =
|
|
103
|
+
tupleElementTypes == nil && event.dispatch == .scheduled
|
|
104
|
+
? jsConvertibleProtocolName : javaScriptEncodableProtocolName
|
|
102
105
|
let payloads =
|
|
103
106
|
tupleElementTypes.map { distinctAssertableTypes($0, constraint: payloadConstraint) }
|
|
104
107
|
?? event.payloadType.flatMap { assertableBoundaryType($0, constraint: payloadConstraint) }.map { [$0] } ?? []
|
|
@@ -127,8 +130,8 @@ private struct EventMember {
|
|
|
127
130
|
let jsName: String
|
|
128
131
|
/// The payload type as written, or `nil` for a no-payload `() -> Void` event.
|
|
129
132
|
let payloadType: String?
|
|
130
|
-
///
|
|
131
|
-
let
|
|
133
|
+
/// How the event is dispatched, from the `.inline` or `.blocking` option.
|
|
134
|
+
let dispatch: EventDispatch
|
|
132
135
|
|
|
133
136
|
var hasPayload: Bool {
|
|
134
137
|
return payloadType != nil
|
|
@@ -208,14 +211,88 @@ private func validatedEvent(
|
|
|
208
211
|
}
|
|
209
212
|
|
|
210
213
|
let swiftName = identifier.identifier.text
|
|
214
|
+
let dispatch = try eventDispatch(of: node)
|
|
211
215
|
return EventMember(
|
|
212
216
|
swiftName: swiftName,
|
|
213
217
|
jsName: jsNameArgument(of: node) ?? defaultEventName(for: withoutBackticks(swiftName)),
|
|
214
218
|
payloadType: functionType.parameters.first?.type.trimmedDescription,
|
|
215
|
-
|
|
219
|
+
dispatch: dispatch
|
|
216
220
|
)
|
|
217
221
|
}
|
|
218
222
|
|
|
223
|
+
/// How an `@Event` is dispatched to JavaScript, picked by its option.
|
|
224
|
+
private enum EventDispatch {
|
|
225
|
+
case scheduled
|
|
226
|
+
case inline
|
|
227
|
+
case blocking
|
|
228
|
+
|
|
229
|
+
var emitMethod: String {
|
|
230
|
+
switch self {
|
|
231
|
+
case .scheduled:
|
|
232
|
+
return "emit"
|
|
233
|
+
case .inline:
|
|
234
|
+
return "emitInline"
|
|
235
|
+
case .blocking:
|
|
236
|
+
return "emitBlocking"
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/// Reads the dispatch option of an `@Event` attribute. `.inline` and `.blocking` exclude each other.
|
|
242
|
+
private func eventDispatch(of node: AttributeSyntax) throws -> EventDispatch {
|
|
243
|
+
let isInline = hasOption(node, named: "inline")
|
|
244
|
+
let isBlocking = hasOption(node, named: "blocking")
|
|
245
|
+
if isInline && isBlocking {
|
|
246
|
+
throw conflictingOptionsDiagnostic(in: node)
|
|
247
|
+
}
|
|
248
|
+
if isInline {
|
|
249
|
+
return .inline
|
|
250
|
+
}
|
|
251
|
+
if isBlocking {
|
|
252
|
+
return .blocking
|
|
253
|
+
}
|
|
254
|
+
return .scheduled
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/// The error for `@Event(.inline, .blocking)`, with a single Fix-It that removes the option written
|
|
258
|
+
/// last. One Fix-It (rather than one for each option) lets the `fix` command apply it, since it skips
|
|
259
|
+
/// a diagnostic with alternatives.
|
|
260
|
+
private func conflictingOptionsDiagnostic(in node: AttributeSyntax) -> DiagnosticsError {
|
|
261
|
+
let message = EventDiagnosticMessage(
|
|
262
|
+
"@Event can't be both '.inline' and '.blocking'; '.blocking' also runs the listeners inline on the JS thread, so remove one of the options",
|
|
263
|
+
id: "event-conflicting-options"
|
|
264
|
+
)
|
|
265
|
+
guard let arguments = node.arguments?.as(LabeledExprListSyntax.self),
|
|
266
|
+
let index = arguments.lastIndex(where: { isDispatchOption($0.expression) })
|
|
267
|
+
else {
|
|
268
|
+
return DiagnosticsError(diagnostics: [Diagnostic(node: node, message: message)])
|
|
269
|
+
}
|
|
270
|
+
let removed = arguments[index]
|
|
271
|
+
var remaining = Array(arguments)
|
|
272
|
+
remaining.removeAll { $0.id == removed.id }
|
|
273
|
+
// The argument before the removed one keeps its comma only when another argument follows it.
|
|
274
|
+
if let last = remaining.indices.last {
|
|
275
|
+
remaining[last] = remaining[last].with(\.trailingComma, nil)
|
|
276
|
+
}
|
|
277
|
+
let optionName = removed.expression.as(MemberAccessExprSyntax.self)?.declName.baseName.text ?? "option"
|
|
278
|
+
let fixIt = FixIt(
|
|
279
|
+
message: EventFixItMessage("Remove '.\(optionName)'", id: "event-remove-option"),
|
|
280
|
+
changes: [
|
|
281
|
+
.replace(oldNode: Syntax(arguments), newNode: Syntax(LabeledExprListSyntax(remaining)))
|
|
282
|
+
]
|
|
283
|
+
)
|
|
284
|
+
return DiagnosticsError(diagnostics: [Diagnostic(node: node, message: message, fixIts: [fixIt])])
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/// True when the expression is the `.inline` or `.blocking` option (or `EventOptions.<option>`).
|
|
288
|
+
private func isDispatchOption(_ expression: ExprSyntax) -> Bool {
|
|
289
|
+
guard let member = expression.as(MemberAccessExprSyntax.self) else {
|
|
290
|
+
return false
|
|
291
|
+
}
|
|
292
|
+
let name = member.declName.baseName.text
|
|
293
|
+
return name == "inline" || name == "blocking"
|
|
294
|
+
}
|
|
295
|
+
|
|
219
296
|
// MARK: - Default event name
|
|
220
297
|
|
|
221
298
|
/// The default JS event name for a property: the Swift name with the conventional `on` prefix
|
|
@@ -167,10 +167,7 @@ extension ExpoModuleMacro: MemberAttributeMacro {
|
|
|
167
167
|
// checked at compile time. Skipped when the member already chose an isolation
|
|
168
168
|
// (`nonisolated` or another global actor), or when `.concurrent` or `.withAsync("name")` runs
|
|
169
169
|
// it on the concurrent pool. See `shouldStampJavaScriptActor`.
|
|
170
|
-
|
|
171
|
-
// isolation forces its call site onto the JS thread. Async events (the default) are
|
|
172
|
-
// deliberately left unstamped — their `emit` schedules onto the JS thread itself.
|
|
173
|
-
if memberHasJSAttribute(member) || isSyncEventMember(member),
|
|
170
|
+
if memberHasJSAttribute(member),
|
|
174
171
|
shouldStampJavaScriptActor(on: member, enclosedBy: declaration)
|
|
175
172
|
{
|
|
176
173
|
attributes.append("@JavaScriptActor")
|
|
@@ -171,10 +171,7 @@ extension SharedObjectMacro: MemberAttributeMacro {
|
|
|
171
171
|
providingAttributesFor member: some DeclSyntaxProtocol,
|
|
172
172
|
in context: some MacroExpansionContext
|
|
173
173
|
) throws -> [AttributeSyntax] {
|
|
174
|
-
|
|
175
|
-
// inline, so the isolation forces its call site onto the JS thread. Async events (the
|
|
176
|
-
// default) stay unstamped — their `emit` schedules onto the JS thread itself.
|
|
177
|
-
guard memberHasJSAttribute(member) || isSyncEventMember(member) else {
|
|
174
|
+
guard memberHasJSAttribute(member) else {
|
|
178
175
|
return []
|
|
179
176
|
}
|
|
180
177
|
// `@JS(.concurrent)` opts the member out of the JS-thread stamp and onto the concurrent pool.
|
|
@@ -134,7 +134,9 @@ private let maxValueProps = 64
|
|
|
134
134
|
/// The error for a leftover `@Field`, attached to the attribute itself and carrying a fix-it that
|
|
135
135
|
/// deletes it. `@ViewProps` treats every stored property as a prop, so the attribute has no meaning
|
|
136
136
|
/// here; left in place it would wrap the value in `Field<T>` and the props type would decode against
|
|
137
|
-
/// the wrong type.
|
|
137
|
+
/// the wrong type. A `@Field` with arguments, such as `@Field(.keyed("borderColor"))`, gets no
|
|
138
|
+
/// fix-it: they can set the prop name, which removing the attribute would change silently, so the
|
|
139
|
+
/// message tells the author to rename the property.
|
|
138
140
|
private func fieldAttributeDiagnostic(
|
|
139
141
|
for attribute: AttributeSyntax,
|
|
140
142
|
on varDecl: VariableDeclSyntax
|
|
@@ -173,11 +175,14 @@ private func fieldAttributeDiagnostic(
|
|
|
173
175
|
)
|
|
174
176
|
]
|
|
175
177
|
)
|
|
178
|
+
let hasArguments = attribute.arguments?.as(LabeledExprListSyntax.self)?.isEmpty == false
|
|
176
179
|
let message = ViewPropsDiagnosticMessage(
|
|
177
|
-
|
|
180
|
+
hasArguments
|
|
181
|
+
? "@Field is no longer used: @ViewProps treats every stored property as a prop, named after the property. Remove the @Field attribute, and rename the property if its arguments set another prop name"
|
|
182
|
+
: "@Field is no longer used: @ViewProps treats every stored property as a prop. Remove the @Field attribute",
|
|
178
183
|
id: "viewprops-field-attribute"
|
|
179
184
|
)
|
|
180
|
-
return Diagnostic(node: attribute, message: message, fixIts: [fixIt])
|
|
185
|
+
return Diagnostic(node: attribute, message: message, fixIts: hasArguments ? [] : [fixIt])
|
|
181
186
|
}
|
|
182
187
|
|
|
183
188
|
/// Replaces the leading trivia of one token.
|
|
@@ -311,6 +316,22 @@ private func validatedViewProps(of declaration: some DeclGroupSyntax) throws ->
|
|
|
311
316
|
)
|
|
312
317
|
}
|
|
313
318
|
|
|
319
|
+
// `@Field` is the v1 property wrapper and has no meaning here — every stored property is already
|
|
320
|
+
// part of the surface. Left in place it would wrap the value in `Field<T>`, so the props type
|
|
321
|
+
// would decode against the wrong type. Flag every one rather than emit code built on them, all at
|
|
322
|
+
// once, so a `@Field` without a fix-it doesn't hide the fix-its of the others.
|
|
323
|
+
let fieldDiagnostics = declaration.memberBlock.members.compactMap { member -> Diagnostic? in
|
|
324
|
+
guard let varDecl = member.decl.as(VariableDeclSyntax.self), !isExcludedByModifier(varDecl.modifiers),
|
|
325
|
+
let fieldAttribute = varDecl.attributes.firstAttribute(named: "Field")
|
|
326
|
+
else {
|
|
327
|
+
return nil
|
|
328
|
+
}
|
|
329
|
+
return fieldAttributeDiagnostic(for: fieldAttribute, on: varDecl)
|
|
330
|
+
}
|
|
331
|
+
if !fieldDiagnostics.isEmpty {
|
|
332
|
+
throw DiagnosticsError(diagnostics: fieldDiagnostics)
|
|
333
|
+
}
|
|
334
|
+
|
|
314
335
|
var valueProps: [ViewProp] = []
|
|
315
336
|
var eventProps: [ViewProp] = []
|
|
316
337
|
|
|
@@ -321,13 +342,6 @@ private func validatedViewProps(of declaration: some DeclGroupSyntax) throws ->
|
|
|
321
342
|
if isExcludedByModifier(varDecl.modifiers) {
|
|
322
343
|
continue
|
|
323
344
|
}
|
|
324
|
-
// `@Field` is the v1 property wrapper and has no meaning here — every stored property is already
|
|
325
|
-
// part of the surface. Left in place it would wrap the value in `Field<T>`, so the props type
|
|
326
|
-
// would decode against the wrong type. Flag it rather than emit code built on it.
|
|
327
|
-
if let fieldAttribute = varDecl.attributes.firstAttribute(named: "Field") {
|
|
328
|
-
throw DiagnosticsError(diagnostics: [fieldAttributeDiagnostic(for: fieldAttribute, on: varDecl)])
|
|
329
|
-
}
|
|
330
|
-
|
|
331
345
|
for binding in varDecl.bindings {
|
|
332
346
|
// A computed property is never a prop, but an accessor block alone doesn't mean computed:
|
|
333
347
|
// `willSet`/`didSet` observers imply stored storage. `bindingIsSettable` draws exactly that
|
|
@@ -13,8 +13,8 @@ internal func jsNameArgument(of attribute: AttributeSyntax) -> String? {
|
|
|
13
13
|
return segment.content.text
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/// True if the attribute lists the given option member, e.g. `@JS(.concurrent)`, `@Event(.
|
|
17
|
-
/// `@Event("name", EventOptions.
|
|
16
|
+
/// True if the attribute lists the given option member, e.g. `@JS(.concurrent)`, `@Event(.inline)` or
|
|
17
|
+
/// `@Event("name", EventOptions.inline)` -> true for the matching name. Options are written as
|
|
18
18
|
/// member-access expressions, one per variadic argument. Array literals are scanned too, though the
|
|
19
19
|
/// variadic declarations in `expo-modules-core` don't accept them. The leading string literal, when
|
|
20
20
|
/// present, is the JS name and is skipped.
|
|
@@ -276,20 +276,6 @@ internal func hasDefaultValue(_ parameter: FunctionParameterSyntax) -> Bool {
|
|
|
276
276
|
return parameter.defaultValue != nil
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
-
/// True if the declaration is a `@Event(.sync)` property. A sync event dispatches inline on the
|
|
280
|
-
/// JS thread instead of scheduling, so `@ExpoModule`/`@SharedObject` stamp it with `@JavaScriptActor`,
|
|
281
|
-
/// making "must be called on the JS thread" a compile-time guarantee at the call site. Async events
|
|
282
|
-
/// (the default) are deliberately not stamped: their `emit` schedules onto the JS thread itself, so
|
|
283
|
-
/// they stay callable from any thread.
|
|
284
|
-
internal func isSyncEventMember(_ decl: DeclSyntaxProtocol) -> Bool {
|
|
285
|
-
guard let varDecl = decl.as(VariableDeclSyntax.self),
|
|
286
|
-
let attribute = varDecl.attributes.firstAttribute(named: "Event")
|
|
287
|
-
else {
|
|
288
|
-
return false
|
|
289
|
-
}
|
|
290
|
-
return hasOption(attribute, named: "sync")
|
|
291
|
-
}
|
|
292
|
-
|
|
293
279
|
/// Reads a labeled array-literal argument of an attribute, e.g. `@ExpoModule(classes: [Foo.self, Bar.self])`,
|
|
294
280
|
/// and returns the type names referenced (e.g. `["Foo", "Bar"]`). Each element must be a
|
|
295
281
|
/// `<TypeName>.self` member-access expression; non-conforming elements are skipped silently.
|
package/build/types.d.ts
CHANGED
|
@@ -260,8 +260,8 @@ export interface ExportedEvent extends ExportedAccess, ExportedAvailability {
|
|
|
260
260
|
jsName: string;
|
|
261
261
|
/** The payload type, absent for a no-payload `() -> Void` event. */
|
|
262
262
|
payload?: TypeNode;
|
|
263
|
-
/**
|
|
264
|
-
|
|
263
|
+
/** How the event is dispatched: the default `scheduled`, or the `.inline` or `.blocking` option. */
|
|
264
|
+
dispatch: 'scheduled' | 'inline' | 'blocking';
|
|
265
265
|
}
|
|
266
266
|
/** The `@JS init` of a shared object: the constructor JS calls with `new`. */
|
|
267
267
|
export interface ExportedInitializer extends ExportedAccess, ExportedAvailability {
|
|
@@ -480,5 +480,5 @@ export interface ExpandResult {
|
|
|
480
480
|
* as a clear error instead of silently misread fields.
|
|
481
481
|
*/
|
|
482
482
|
export declare const SUPPORTED_SCAN_MODULES_SCHEMA_VERSION = 2;
|
|
483
|
-
export declare const SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION =
|
|
483
|
+
export declare const SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 11;
|
|
484
484
|
export declare const SUPPORTED_EXPAND_SCHEMA_VERSION = 1;
|
package/build/types.js
CHANGED
|
@@ -12,5 +12,5 @@ exports.SUPPORTED_EXPAND_SCHEMA_VERSION = exports.SUPPORTED_SCAN_EXPORTS_SCHEMA_
|
|
|
12
12
|
* as a clear error instead of silently misread fields.
|
|
13
13
|
*/
|
|
14
14
|
exports.SUPPORTED_SCAN_MODULES_SCHEMA_VERSION = 2;
|
|
15
|
-
exports.SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION =
|
|
15
|
+
exports.SUPPORTED_SCAN_EXPORTS_SCHEMA_VERSION = 11;
|
|
16
16
|
exports.SUPPORTED_EXPAND_SCHEMA_VERSION = 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-modules-macros",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Swift macro plugin for Expo modules",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"types": "build/index.d.ts",
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
"typescript": "^5.4.0"
|
|
27
27
|
},
|
|
28
28
|
"optionalDependencies": {
|
|
29
|
-
"@expo/modules-macros-linux-arm64": "0.
|
|
30
|
-
"@expo/modules-macros-linux-x64": "0.
|
|
31
|
-
"@expo/modules-macros-win32-arm64": "0.
|
|
32
|
-
"@expo/modules-macros-win32-x64": "0.
|
|
29
|
+
"@expo/modules-macros-linux-arm64": "0.31.0",
|
|
30
|
+
"@expo/modules-macros-linux-x64": "0.31.0",
|
|
31
|
+
"@expo/modules-macros-win32-arm64": "0.31.0",
|
|
32
|
+
"@expo/modules-macros-win32-x64": "0.31.0"
|
|
33
33
|
}
|
|
34
34
|
}
|