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.
Files changed (47) hide show
  1. package/.github/workflows/linux.yml +3 -7
  2. package/.github/workflows/publish.yml +2 -3
  3. package/.github/workflows/swift.yml +7 -13
  4. package/.github/workflows/windows.yml +3 -10
  5. package/AGENTS.md +11 -8
  6. package/Package.swift +110 -0
  7. package/README.md +56 -8
  8. package/apple/ExpoModulesMacros +0 -0
  9. package/apple/Sources/ExpoModulesCLI/CLI.swift +47 -10
  10. package/apple/Sources/ExpoModulesCLI/Commands/Expand/ExpandReport.swift +37 -1
  11. package/apple/Sources/{ExpoModulesMacros → ExpoModulesCLI/Commands}/Expand/MacroExpander.swift +118 -27
  12. package/apple/Sources/ExpoModulesCLI/Commands/Expand/RunExpand.swift +20 -10
  13. package/apple/Sources/ExpoModulesCLI/Commands/Fix/RunFix.swift +228 -0
  14. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/ExportedSurface.swift +1 -1
  15. package/apple/Sources/ExpoModulesCLI/Commands/ScanExports/SurfaceVisitor.swift +8 -10
  16. package/apple/Sources/ExpoModulesCLI/Commands/ScanModules/ScanModules.swift +48 -34
  17. package/apple/Sources/ExpoModulesMacros/main.swift +6 -0
  18. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/EventMacro.swift +3 -3
  19. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/ExpoModuleMacro.swift +8 -1
  20. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/JSMacro.swift +54 -1
  21. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/SharedObjectMacro.swift +9 -2
  22. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/ViewPropsMacro.swift +36 -6
  23. package/apple/Sources/ExpoModulesMacrosCore/PluginServer.swift +40 -0
  24. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/ProvidedMacros.swift +5 -5
  25. package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/MacroHelpers.swift +12 -26
  26. package/apple/Sources/ExpoModulesMacrosCore/Support/PublicAccess.swift +105 -0
  27. package/apple/Sources/ExpoModulesMacrosTool/main.swift +27 -0
  28. package/build/types.d.ts +15 -6
  29. package/package.json +5 -5
  30. package/apple/Package.swift +0 -66
  31. package/apple/Sources/ExpoModulesMacros/Plugin.swift +0 -36
  32. /package/{apple/Package.resolved → Package.resolved} +0 -0
  33. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/ExpoModulesOptimizedMacro.swift +0 -0
  34. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/ExpoViewMacro.swift +0 -0
  35. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/RecordMacro.swift +0 -0
  36. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Macros/UnionMacro.swift +0 -0
  37. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/ClosureArguments.swift +0 -0
  38. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/DecorateModuleBuilder.swift +0 -0
  39. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/FreeFormTypes.swift +0 -0
  40. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/JSConstructor.swift +0 -0
  41. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/JSNameCollisions.swift +0 -0
  42. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/OptimizedFunctionHelpers.swift +0 -0
  43. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/Receiver.swift +0 -0
  44. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/SendableConformance.swift +0 -0
  45. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/TupleTypes.swift +0 -0
  46. /package/apple/Sources/{ExpoModulesMacros → ExpoModulesMacrosCore}/Support/TypeAliases.swift +0 -0
  47. /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
 
@@ -1,4 +1,4 @@
1
- import ExpoModulesCLI
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
- expansionDiagnostic(diagnostic) { position, node in
38
- let location = context.location(for: position, anchoredAt: node, fileName: file)
39
- if let original = originalDeclarations.position(of: position, in: node, shiftedOffset: location.offset) {
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 text from the keyword of the declaration (`func`, `var`, `init`) to its end is the same in
227
- /// both, so this finds the original declaration by that text and moves the position by the offset of
228
- /// the keyword. A position before the keyword, in the attributes or the modifiers, isn't mapped.
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 positions of the keywords of the declarations in the original file, in source order, by the
231
- /// text from the keyword to the end of the declaration.
232
- private let keywordPositions: [String: [AbsolutePosition]]
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 keywordPositions: [String: [AbsolutePosition]] = [:]
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
- keywordPositions[textFromKeyword(keyword, of: declaration), default: []].append(
243
- keyword.positionAfterSkippingLeadingTrivia
336
+ declarations[declaration.position.utf8Offset, default: []].append(
337
+ (textFromKeyword(keyword, of: declaration), keyword.positionAfterSkippingLeadingTrivia)
244
338
  )
245
339
  }
246
- self.keywordPositions = keywordPositions
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, or is before its keyword.
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
- /// `shiftedOffset` is the offset that the macro system maps `position` to. The added attributes
253
- /// only move it forward, so when declarations with the same text are in the file, the one it is
254
- /// in is the last one that starts at or before that offset.
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 candidates = keywordPositions[textFromKeyword(keyword, of: declaration)]
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
- let shiftedKeyword = shiftedOffset - offset
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: true)`, dispatching inline on the JS thread instead of scheduling.
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 = boolArgument(of: attribute, label: "sync") == true
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
- /// The value of a labeled boolean macro argument (`@Event(sync: true)`), or `nil` when absent.
708
- private func boolArgument(of attribute: AttributeSyntax, label: String) -> Bool? {
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 nil
712
+ return false
711
713
  }
712
- for argument in arguments where argument.label?.text == label {
713
- guard let literal = argument.expression.as(BooleanLiteralExprSyntax.self) else {
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