expo-modules-macros 0.28.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/.github/workflows/linux.yml +3 -7
- package/.github/workflows/publish.yml +2 -3
- package/.github/workflows/swift.yml +7 -13
- package/.github/workflows/windows.yml +3 -10
- package/AGENTS.md +11 -8
- package/Package.swift +110 -0
- package/README.md +56 -8
- package/apple/ExpoModulesMacros +0 -0
- package/apple/Sources/ExpoModulesCLI/CLI.swift +47 -10
- package/apple/Sources/ExpoModulesCLI/Commands/Expand/ExpandReport.swift +37 -1
- package/apple/Sources/{ExpoModulesMacros → ExpoModulesCLI/Commands}/Expand/MacroExpander.swift +118 -27
- 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/ExpoModulesMacros/main.swift +6 -0
- package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/EventMacro.swift +3 -3
- package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/ExpoModuleMacro.swift +8 -1
- package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/JSMacro.swift +54 -1
- package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/SharedObjectMacro.swift +9 -2
- package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/ViewPropsMacro.swift +36 -6
- package/apple/Sources/ExpoModulesMacrosCore/PluginServer.swift +40 -0
- package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/ProvidedMacros.swift +5 -5
- package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/MacroHelpers.swift +12 -26
- package/apple/Sources/ExpoModulesMacrosCore/Support/PublicAccess.swift +105 -0
- package/apple/Sources/ExpoModulesMacrosTool/main.swift +27 -0
- package/build/types.d.ts +15 -6
- package/package.json +5 -5
- package/apple/Package.swift +0 -66
- package/apple/Sources/ExpoModulesMacros/Plugin.swift +0 -36
- /package/{apple/Package.resolved → Package.resolved} +0 -0
- /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/ExpoModulesOptimizedMacro.swift +0 -0
- /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/ExpoViewMacro.swift +0 -0
- /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/RecordMacro.swift +0 -0
- /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/UnionMacro.swift +0 -0
- /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/ClosureArguments.swift +0 -0
- /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/DecorateModuleBuilder.swift +0 -0
- /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/FreeFormTypes.swift +0 -0
- /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/JSConstructor.swift +0 -0
- /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/JSNameCollisions.swift +0 -0
- /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/OptimizedFunctionHelpers.swift +0 -0
- /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/Receiver.swift +0 -0
- /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/SendableConformance.swift +0 -0
- /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/TupleTypes.swift +0 -0
- /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/TypeAliases.swift +0 -0
- /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/TypeConformanceAssertion.swift +0 -0
|
@@ -67,13 +67,29 @@ public struct ExpansionDiagnostic: Codable, Equatable, Sendable {
|
|
|
67
67
|
/// The messages of the Fix-Its, for example `replace 'var' with 'let'`.
|
|
68
68
|
public let fixIts: [String]
|
|
69
69
|
|
|
70
|
+
/// The edits of each Fix-It in `fixIts`, in the original file, for `fix`. The edits of a Fix-It are
|
|
71
|
+
/// empty when they aren't in the original file, and this is empty for a diagnostic whose Fix-Its
|
|
72
|
+
/// `fix` doesn't apply, such as a syntax error. Not part of the JSON report, nor of equality, so a
|
|
73
|
+
/// diagnostic is equal to itself after a JSON round trip.
|
|
74
|
+
public var fixItEdits: [[ExpansionEdit]] = []
|
|
75
|
+
|
|
76
|
+
private enum CodingKeys: String, CodingKey {
|
|
77
|
+
case severity, message, line, column, notes, fixIts
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public static func == (lhs: Self, rhs: Self) -> Bool {
|
|
81
|
+
lhs.severity == rhs.severity && lhs.message == rhs.message && lhs.line == rhs.line
|
|
82
|
+
&& lhs.column == rhs.column && lhs.notes == rhs.notes && lhs.fixIts == rhs.fixIts
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
public init(
|
|
71
86
|
severity: Severity,
|
|
72
87
|
message: String,
|
|
73
88
|
line: Int,
|
|
74
89
|
column: Int,
|
|
75
90
|
notes: [ExpansionNote] = [],
|
|
76
|
-
fixIts: [String] = []
|
|
91
|
+
fixIts: [String] = [],
|
|
92
|
+
fixItEdits: [[ExpansionEdit]] = []
|
|
77
93
|
) {
|
|
78
94
|
self.severity = severity
|
|
79
95
|
self.message = message
|
|
@@ -81,6 +97,26 @@ public struct ExpansionDiagnostic: Codable, Equatable, Sendable {
|
|
|
81
97
|
self.column = column
|
|
82
98
|
self.notes = notes
|
|
83
99
|
self.fixIts = fixIts
|
|
100
|
+
self.fixItEdits = fixItEdits
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// A change of one range of the original file: `original`, which starts at `offset`, becomes
|
|
105
|
+
/// `replacement`.
|
|
106
|
+
public struct ExpansionEdit: Equatable, Sendable {
|
|
107
|
+
/// The UTF-8 offset in the original file.
|
|
108
|
+
public let offset: Int
|
|
109
|
+
|
|
110
|
+
/// The text that the edit replaces, so `fix` can check that the file still has it there. Empty
|
|
111
|
+
/// for an insertion.
|
|
112
|
+
public let original: String
|
|
113
|
+
|
|
114
|
+
public let replacement: String
|
|
115
|
+
|
|
116
|
+
public init(offset: Int, original: String, replacement: String) {
|
|
117
|
+
self.offset = offset
|
|
118
|
+
self.original = original
|
|
119
|
+
self.replacement = replacement
|
|
84
120
|
}
|
|
85
121
|
}
|
|
86
122
|
|
package/apple/Sources/{ExpoModulesMacros → ExpoModulesCLI/Commands}/Expand/MacroExpander.swift
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import ExpoModulesMacrosCore
|
|
2
2
|
import SwiftBasicFormat
|
|
3
3
|
import SwiftDiagnostics
|
|
4
4
|
import SwiftParser
|
|
@@ -34,13 +34,18 @@ public func expandMacros(source: String, file: String, symbols: [String]) -> Exp
|
|
|
34
34
|
}
|
|
35
35
|
let originalDeclarations = OriginalDeclarations(tree)
|
|
36
36
|
diagnostics += context.diagnostics.map { diagnostic in
|
|
37
|
-
|
|
38
|
-
let
|
|
39
|
-
if let original = originalDeclarations.position(of: position, in: node,
|
|
37
|
+
let location = { (position: AbsolutePosition, node: Syntax) -> SourceLocation in
|
|
38
|
+
let declarationStart = context.location(for: node.root.position, anchoredAt: node, fileName: file).offset
|
|
39
|
+
if let original = originalDeclarations.position(of: position, in: node, declarationStart: declarationStart) {
|
|
40
40
|
return converter.location(for: original)
|
|
41
41
|
}
|
|
42
|
-
return location
|
|
42
|
+
return context.location(for: position, anchoredAt: node, fileName: file)
|
|
43
43
|
}
|
|
44
|
+
var result = expansionDiagnostic(diagnostic, location: location)
|
|
45
|
+
result.fixItEdits = diagnostic.fixIts.map { fixIt in
|
|
46
|
+
originalEdits(of: fixIt) { location($0, $1).offset } ?? []
|
|
47
|
+
}
|
|
48
|
+
return result
|
|
44
49
|
}
|
|
45
50
|
diagnostics.sort { ($0.line, $0.column) < ($1.line, $1.column) }
|
|
46
51
|
|
|
@@ -203,6 +208,93 @@ private func expansionDiagnostic(
|
|
|
203
208
|
)
|
|
204
209
|
}
|
|
205
210
|
|
|
211
|
+
/// The edits of `fixIt` in the original file, or `nil` when one of them isn't in the tree of the node
|
|
212
|
+
/// that it changes. `offset` maps a position in the tree of a node to its UTF-8 offset in the original
|
|
213
|
+
/// file.
|
|
214
|
+
///
|
|
215
|
+
/// A Fix-It that replaces a node replaces all of it, but the tree that a macro gets isn't always the
|
|
216
|
+
/// original declaration: in a module, a `@JS` member also has the attributes that `@ExpoModule` adds.
|
|
217
|
+
/// So each edit is reduced to the bytes that it changes, for example the `async ` that it inserts
|
|
218
|
+
/// into a signature, which are in the original file too. The macro system detaches the attribute
|
|
219
|
+
/// from the declaration, so a change has its own tree, which can differ from the diagnostic's.
|
|
220
|
+
private func originalEdits(
|
|
221
|
+
of fixIt: FixIt,
|
|
222
|
+
offset: (AbsolutePosition, Syntax) -> Int
|
|
223
|
+
) -> [ExpansionEdit]? {
|
|
224
|
+
var edits: [ExpansionEdit] = []
|
|
225
|
+
for change in fixIt.changes {
|
|
226
|
+
// `FixIt.edits` drops an edit that overlaps an earlier one; this applies the same rule.
|
|
227
|
+
guard let edit = FixIt(message: fixIt.message, changes: [change]).edits.first else {
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
let node = changedNode(change)
|
|
231
|
+
let root = node.root
|
|
232
|
+
let text = Array(root.description.utf8)
|
|
233
|
+
let start = edit.range.lowerBound.utf8Offset - root.position.utf8Offset
|
|
234
|
+
let end = edit.range.upperBound.utf8Offset - root.position.utf8Offset
|
|
235
|
+
guard 0 <= start, start <= end, end <= text.count else {
|
|
236
|
+
return nil
|
|
237
|
+
}
|
|
238
|
+
let old = text[start..<end]
|
|
239
|
+
let new = Array(edit.replacement.utf8)
|
|
240
|
+
let (prefix, suffix) = commonAffixLengths(Array(old), new)
|
|
241
|
+
guard prefix + suffix < max(old.count, new.count) else {
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
let changedStart = edit.range.lowerBound.advanced(by: prefix)
|
|
245
|
+
let originalEdit = ExpansionEdit(
|
|
246
|
+
offset: offset(changedStart, node),
|
|
247
|
+
original: String(decoding: old.dropFirst(prefix).dropLast(suffix), as: UTF8.self),
|
|
248
|
+
replacement: String(decoding: new.dropFirst(prefix).dropLast(suffix), as: UTF8.self)
|
|
249
|
+
)
|
|
250
|
+
if !edits.contains(where: { overlaps($0, originalEdit) }) {
|
|
251
|
+
edits.append(originalEdit)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return edits
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/// The node in the tree that `change` edits.
|
|
258
|
+
private func changedNode(_ change: FixIt.Change) -> Syntax {
|
|
259
|
+
switch change {
|
|
260
|
+
case .replace(let oldNode, _):
|
|
261
|
+
oldNode
|
|
262
|
+
case .replaceLeadingTrivia(let token, _), .replaceTrailingTrivia(let token, _):
|
|
263
|
+
Syntax(token)
|
|
264
|
+
case .replaceChild(let data):
|
|
265
|
+
Syntax(data.parent)
|
|
266
|
+
case .replaceText(_, _, let node):
|
|
267
|
+
node
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
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
|
+
/// The lengths of the longest common prefix and suffix of `old` and `new` that don't overlap, at
|
|
277
|
+
/// character boundaries, so the bytes between them are valid UTF-8 on both sides.
|
|
278
|
+
private func commonAffixLengths(_ old: [UInt8], _ new: [UInt8]) -> (prefix: Int, suffix: Int) {
|
|
279
|
+
let isContinuation = { (bytes: [UInt8], index: Int) in index < bytes.count && bytes[index] & 0xC0 == 0x80 }
|
|
280
|
+
let shorter = min(old.count, new.count)
|
|
281
|
+
var prefix = 0
|
|
282
|
+
while prefix < shorter && old[prefix] == new[prefix] {
|
|
283
|
+
prefix += 1
|
|
284
|
+
}
|
|
285
|
+
while prefix > 0 && (isContinuation(old, prefix) || isContinuation(new, prefix)) {
|
|
286
|
+
prefix -= 1
|
|
287
|
+
}
|
|
288
|
+
var suffix = 0
|
|
289
|
+
while suffix < shorter - prefix && old[old.count - 1 - suffix] == new[new.count - 1 - suffix] {
|
|
290
|
+
suffix += 1
|
|
291
|
+
}
|
|
292
|
+
while suffix > 0 && (isContinuation(old, old.count - suffix) || isContinuation(new, new.count - suffix)) {
|
|
293
|
+
suffix -= 1
|
|
294
|
+
}
|
|
295
|
+
return (prefix, suffix)
|
|
296
|
+
}
|
|
297
|
+
|
|
206
298
|
private func severity(_ severity: DiagnosticSeverity) -> ExpansionDiagnostic.Severity {
|
|
207
299
|
switch severity {
|
|
208
300
|
case .error: .error
|
|
@@ -223,51 +315,50 @@ private func severity(_ severity: DiagnosticSeverity) -> ExpansionDiagnostic.Sev
|
|
|
223
315
|
/// every position after the added attributes is off by their length. A diagnostic in a parameter
|
|
224
316
|
/// type of a `@JS` function would be on the next line.
|
|
225
317
|
///
|
|
226
|
-
/// The
|
|
227
|
-
///
|
|
228
|
-
///
|
|
318
|
+
/// The start of the declaration is before the added attributes, so the macro system maps it to the
|
|
319
|
+
/// start of the original declaration. The text from the keyword of the declaration (`func`, `var`,
|
|
320
|
+
/// `init`) to its end is the same in both, so this moves the position by the offset of the keyword
|
|
321
|
+
/// in the original declaration. A position before the keyword, in the attributes or the modifiers,
|
|
322
|
+
/// isn't mapped.
|
|
229
323
|
private struct OriginalDeclarations {
|
|
230
|
-
/// The
|
|
231
|
-
/// text from the keyword to the end of the
|
|
232
|
-
private let
|
|
324
|
+
/// The declarations in the original file by the UTF-8 offset of their start, with their leading
|
|
325
|
+
/// trivia: the text from the keyword to the end of each, and the position of its keyword.
|
|
326
|
+
private let declarations: [Int: [(text: String, keyword: AbsolutePosition)]]
|
|
233
327
|
|
|
234
328
|
init(_ tree: SourceFileSyntax) {
|
|
235
329
|
let collector = DeclarationCollector(viewMode: .sourceAccurate)
|
|
236
330
|
collector.walk(tree)
|
|
237
|
-
var
|
|
331
|
+
var declarations: [Int: [(text: String, keyword: AbsolutePosition)]] = [:]
|
|
238
332
|
for declaration in collector.declarations {
|
|
239
333
|
guard let keyword = keywordToken(of: declaration) else {
|
|
240
334
|
continue
|
|
241
335
|
}
|
|
242
|
-
|
|
243
|
-
keyword.positionAfterSkippingLeadingTrivia
|
|
336
|
+
declarations[declaration.position.utf8Offset, default: []].append(
|
|
337
|
+
(textFromKeyword(keyword, of: declaration), keyword.positionAfterSkippingLeadingTrivia)
|
|
244
338
|
)
|
|
245
339
|
}
|
|
246
|
-
self.
|
|
340
|
+
self.declarations = declarations
|
|
247
341
|
}
|
|
248
342
|
|
|
249
343
|
/// The position in the original file of `position` in `node`, or `nil` when `node` is in the
|
|
250
|
-
/// original file, isn't in a detached declaration,
|
|
344
|
+
/// original file, isn't in a detached declaration, is before its keyword, or no declaration with its
|
|
345
|
+
/// text starts at `declarationStart`.
|
|
251
346
|
///
|
|
252
|
-
/// `
|
|
253
|
-
///
|
|
254
|
-
|
|
255
|
-
func position(of position: AbsolutePosition, in node: Syntax, shiftedOffset: Int) -> AbsolutePosition? {
|
|
347
|
+
/// `declarationStart` is the offset that the macro system maps the start of the detached
|
|
348
|
+
/// declaration to.
|
|
349
|
+
func position(of position: AbsolutePosition, in node: Syntax, declarationStart: Int) -> AbsolutePosition? {
|
|
256
350
|
guard !node.root.is(SourceFileSyntax.self),
|
|
257
351
|
let declaration = node.root.as(DeclSyntax.self),
|
|
258
352
|
let keyword = keywordToken(of: declaration),
|
|
259
353
|
position >= keyword.positionAfterSkippingLeadingTrivia,
|
|
260
|
-
let
|
|
354
|
+
let original = declarations[declarationStart]?.first(where: {
|
|
355
|
+
$0.text == textFromKeyword(keyword, of: declaration)
|
|
356
|
+
})
|
|
261
357
|
else {
|
|
262
358
|
return nil
|
|
263
359
|
}
|
|
264
360
|
let offset = position.utf8Offset - keyword.positionAfterSkippingLeadingTrivia.utf8Offset
|
|
265
|
-
|
|
266
|
-
guard let originalKeyword = candidates.last(where: { $0.utf8Offset <= shiftedKeyword }) ?? candidates.first
|
|
267
|
-
else {
|
|
268
|
-
return nil
|
|
269
|
-
}
|
|
270
|
-
return AbsolutePosition(utf8Offset: originalKeyword.utf8Offset + offset)
|
|
361
|
+
return AbsolutePosition(utf8Offset: original.keyword.utf8Offset + offset)
|
|
271
362
|
}
|
|
272
363
|
}
|
|
273
364
|
|
|
@@ -75,6 +75,23 @@ struct ExpandInputError: Error, Equatable {
|
|
|
75
75
|
/// and only the files that use one of the macros are expanded, with their absolute paths. A file that
|
|
76
76
|
/// a directory also contains is expanded once.
|
|
77
77
|
func expandFiles(options: ExpandOptions, expander: MacroExpander) -> Result<ExpandResult, ExpandInputError> {
|
|
78
|
+
expandSources(options: options, expander: expander).map { sources in
|
|
79
|
+
let files = sources.map(\.expanded)
|
|
80
|
+
return ExpandResult(
|
|
81
|
+
schemaVersion: expandSchemaVersion,
|
|
82
|
+
files: options.diagnosticsOnly
|
|
83
|
+
? files.map { ExpandedFile(file: $0.file, expansions: [], diagnostics: $0.diagnostics) }
|
|
84
|
+
: files
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// The files of `options` with their sources, expanded with `expander`, as `expandFiles` describes.
|
|
90
|
+
/// `fix` uses it too, because it edits the sources.
|
|
91
|
+
func expandSources(
|
|
92
|
+
options: ExpandOptions,
|
|
93
|
+
expander: MacroExpander
|
|
94
|
+
) -> Result<[(source: String, expanded: ExpandedFile)], ExpandInputError> {
|
|
78
95
|
let prefilter = MacroPrefilter(attributeNames: expander.attributeNames)
|
|
79
96
|
var sources: [(file: String, source: String)] = []
|
|
80
97
|
var seen: Set<String> = []
|
|
@@ -107,9 +124,9 @@ func expandFiles(options: ExpandOptions, expander: MacroExpander) -> Result<Expa
|
|
|
107
124
|
}
|
|
108
125
|
}
|
|
109
126
|
|
|
110
|
-
let files = sources.map { expander.expand($0.source, $0.file, options.symbols) }
|
|
127
|
+
let files = sources.map { (source: $0.source, expanded: expander.expand($0.source, $0.file, options.symbols)) }
|
|
111
128
|
|
|
112
|
-
let found = Set(files.flatMap { $0.expansions.compactMap(\.symbol) })
|
|
129
|
+
let found = Set(files.flatMap { $0.expanded.expansions.compactMap(\.symbol) })
|
|
113
130
|
if let missing = options.symbols.first(where: { !found.contains($0) }) {
|
|
114
131
|
return .failure(
|
|
115
132
|
ExpandInputError(
|
|
@@ -119,14 +136,7 @@ func expandFiles(options: ExpandOptions, expander: MacroExpander) -> Result<Expa
|
|
|
119
136
|
)
|
|
120
137
|
}
|
|
121
138
|
|
|
122
|
-
return .success(
|
|
123
|
-
ExpandResult(
|
|
124
|
-
schemaVersion: expandSchemaVersion,
|
|
125
|
-
files: options.diagnosticsOnly
|
|
126
|
-
? files.map { ExpandedFile(file: $0.file, expansions: [], diagnostics: $0.diagnostics) }
|
|
127
|
-
: files
|
|
128
|
-
)
|
|
129
|
-
)
|
|
139
|
+
return .success(files)
|
|
130
140
|
}
|
|
131
141
|
|
|
132
142
|
/// The expanded sources as text. A single expansion is printed as it is; more than one each get a
|
|
@@ -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
|