Skip to content

Commit 64b4746

Browse files
SwiftFixIt: Misc improvements (#8671)
### Modifications: * Test reorg. * More tests. * When we skip primary diagnostics, skip their notes too. * Skip primary diagnostics with more than 1 note with fix-its. * Deduplicate primary diagnostics.
1 parent 0c47332 commit 64b4746

File tree

6 files changed

+1539
-750
lines changed

6 files changed

+1539
-750
lines changed

Sources/SwiftFixIt/SwiftFixIt.swift

Lines changed: 155 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@
1212

1313
import struct Basics.AbsolutePath
1414
import protocol Basics.FileSystem
15-
import var Basics.localFileSystem
16-
import struct Basics.SwiftVersion
1715

1816
import struct SwiftDiagnostics.Diagnostic
1917
import struct SwiftDiagnostics.DiagnosticCategory
@@ -44,7 +42,7 @@ private enum Error: Swift.Error {
4442
}
4543

4644
// FIXME: An abstraction for tests to work around missing memberwise initializers in `TSCUtility.SerializedDiagnostics`.
47-
protocol AnySourceLocation {
45+
protocol AnySourceLocation: Hashable {
4846
var filename: String { get }
4947
var line: UInt64 { get }
5048
var column: UInt64 { get }
@@ -75,12 +73,128 @@ protocol AnyDiagnostic {
7573
var fixIts: [FixIt] { get }
7674
}
7775

76+
extension AnyDiagnostic {
77+
var isPrimary: Bool {
78+
self.level != .note
79+
}
80+
81+
var isNote: Bool {
82+
!self.isPrimary
83+
}
84+
85+
var isIgnored: Bool {
86+
self.level == .ignored
87+
}
88+
89+
var hasFixIt: Bool {
90+
!self.fixIts.isEmpty
91+
}
92+
93+
var hasNoLocation: Bool {
94+
self.location == nil
95+
}
96+
}
97+
7898
extension SerializedDiagnostics.Diagnostic: AnyDiagnostic {}
79-
extension SerializedDiagnostics.SourceLocation: AnySourceLocation {}
99+
extension SerializedDiagnostics.SourceLocation: AnySourceLocation, @retroactive Hashable {
100+
public func hash(into hasher: inout Hasher) {
101+
hasher.combine(self.filename)
102+
hasher.combine(self.line)
103+
hasher.combine(self.column)
104+
}
105+
}
106+
80107
extension SerializedDiagnostics.FixIt: AnyFixIt {}
81108

109+
/// Encapsulates initial diagnostic skipping behavior.
110+
private struct PrimaryDiagnosticFilter<Diagnostic: AnyDiagnostic>: ~Copyable {
111+
/// A hashable type storing the minimum data necessary to uniquely identify
112+
/// a diagnostic for our purposes.
113+
private struct DiagnosticID: Hashable {
114+
private let location: Diagnostic.SourceLocation
115+
private let message: String
116+
private let level: SerializedDiagnostics.Diagnostic.Level
117+
118+
init(diagnostic: Diagnostic) {
119+
self.level = diagnostic.level
120+
self.message = diagnostic.text
121+
// Force the location. We should be filtering out diagnostics
122+
// without a location.
123+
self.location = diagnostic.location!
124+
}
125+
}
126+
127+
private var uniquePrimaryDiagnostics: Set<DiagnosticID> = []
128+
129+
let categories: Set<String>
130+
131+
init(categories: Set<String>) {
132+
self.categories = categories
133+
}
134+
135+
/// Returns a Boolean value indicating whether to skip the given primary
136+
/// diagnostic and its notes.
137+
mutating func shouldSkip(primaryDiagnosticWithNotes: some Collection<Diagnostic>) -> Bool {
138+
let diagnostic = primaryDiagnosticWithNotes[primaryDiagnosticWithNotes.startIndex]
139+
precondition(diagnostic.isPrimary)
140+
141+
// Skip if ignored.
142+
if diagnostic.isIgnored {
143+
return true
144+
}
145+
146+
// Skip if no location.
147+
if diagnostic.hasNoLocation {
148+
return true
149+
}
150+
151+
// Skip if categories are given and the diagnostic does not
152+
// belong to any of them.
153+
if !self.categories.isEmpty {
154+
guard let category = diagnostic.category, self.categories.contains(category) else {
155+
return true
156+
}
157+
}
158+
159+
let notes = primaryDiagnosticWithNotes.dropFirst()
160+
precondition(notes.allSatisfy(\.isNote))
161+
162+
// Consider the diagnostic compromised if a note does not have a
163+
// location.
164+
if notes.contains(where: \.hasNoLocation) {
165+
return true
166+
}
167+
168+
switch notes.count(where: \.hasFixIt) {
169+
case 0:
170+
// No notes have fix-its. Skip if neither does the primary
171+
// diagnostic.
172+
guard diagnostic.hasFixIt else {
173+
return true
174+
}
175+
case 1:
176+
break
177+
default:
178+
// Skip if more than 1 note has a fix-it. These diagnostics
179+
// generally require user intervention.
180+
// TODO: This will have to be done lazier once we support printing them.
181+
return true
182+
}
183+
184+
// Skip if we've seen this primary diagnostic before.
185+
//
186+
// NB: This check is done last to prevent the set from growing without
187+
// need.
188+
guard self.uniquePrimaryDiagnostics.insert(.init(diagnostic: diagnostic)).inserted else {
189+
return true
190+
}
191+
192+
return false
193+
}
194+
}
195+
82196
/// The backing API for `SwiftFixitCommand`.
83-
package struct SwiftFixIt /*: ~Copyable */ {
197+
package struct SwiftFixIt /*: ~Copyable */ { // TODO: Crashes with ~Copyable
84198
private typealias DiagnosticsPerFile = [SourceFile: [SwiftDiagnostics.Diagnostic]]
85199

86200
private let fileSystem: any FileSystem
@@ -96,7 +210,7 @@ package struct SwiftFixIt /*: ~Copyable */ {
96210
let diagnostics = try diagnosticFiles.map { path in
97211
let fileContents = try fileSystem.readFileContents(path)
98212
return try TSCUtility.SerializedDiagnostics(bytes: fileContents).diagnostics
99-
}.lazy.joined()
213+
}.joined()
100214

101215
self = try SwiftFixIt(
102216
diagnostics: diagnostics,
@@ -105,62 +219,50 @@ package struct SwiftFixIt /*: ~Copyable */ {
105219
)
106220
}
107221

108-
init(
109-
diagnostics: some Collection<some AnyDiagnostic>,
110-
categories: Set<String> = [],
222+
init<Diagnostic: AnyDiagnostic>(
223+
diagnostics: some Collection<Diagnostic>,
224+
categories: Set<String>,
111225
fileSystem: any FileSystem
112226
) throws {
113227
self.fileSystem = fileSystem
114228

229+
var filter = PrimaryDiagnosticFilter<Diagnostic>(categories: categories)
230+
_ = consume categories
231+
115232
// Build a map from source files to `SwiftDiagnostics` diagnostics.
116233
var diagnosticsPerFile: DiagnosticsPerFile = [:]
117-
118234
var diagnosticConverter = DiagnosticConverter(fileSystem: fileSystem)
119-
var currentPrimaryDiagnosticHasNoteWithFixIt = false
120-
121-
for diagnostic in diagnostics {
122-
let hasFixits = !diagnostic.fixIts.isEmpty
123-
124-
if diagnostic.level == .note {
125-
if hasFixits {
126-
// The Swift compiler produces parallel fix-its by attaching
127-
// them to notes, which in turn associate to a single
128-
// error/warning. Prefer the first fix-it in this case: if
129-
// the last error/warning we saw has a note with a fix-it
130-
// and so is this one, skip it.
131-
if currentPrimaryDiagnosticHasNoteWithFixIt {
132-
continue
133-
}
134-
135-
currentPrimaryDiagnosticHasNoteWithFixIt = true
136-
}
137-
} else {
138-
currentPrimaryDiagnosticHasNoteWithFixIt = false
139-
}
140235

141-
// We are only interested in diagnostics with fix-its.
142-
guard hasFixits else {
143-
continue
144-
}
236+
var nextPrimaryIndex = diagnostics.startIndex
237+
while nextPrimaryIndex != diagnostics.endIndex {
238+
let currentPrimaryIndex = nextPrimaryIndex
239+
precondition(diagnostics[currentPrimaryIndex].isPrimary)
145240

146-
guard let (sourceFile, convertedDiagnostic) =
147-
try diagnosticConverter.diagnostic(from: diagnostic)
148-
else {
241+
// Shift the index to the next primary diagnostic.
242+
repeat {
243+
diagnostics.formIndex(after: &nextPrimaryIndex)
244+
} while nextPrimaryIndex != diagnostics.endIndex && diagnostics[nextPrimaryIndex].isNote
245+
246+
let primaryDiagnosticWithNotes = diagnostics[currentPrimaryIndex ..< nextPrimaryIndex]
247+
248+
if filter.shouldSkip(primaryDiagnosticWithNotes: primaryDiagnosticWithNotes) {
149249
continue
150250
}
151251

152-
if !categories.isEmpty {
153-
guard let category = convertedDiagnostic.diagMessage.category?.name,
154-
categories.contains(category)
155-
else {
252+
for diagnostic in primaryDiagnosticWithNotes {
253+
// We are only interested in diagnostics with fix-its.
254+
// TODO: This will have to change once we support printing them.
255+
guard diagnostic.hasFixIt else {
156256
continue
157257
}
158-
}
159258

160-
diagnosticsPerFile[consume sourceFile, default: []].append(consume convertedDiagnostic)
259+
let (sourceFile, convertedDiagnostic) = try diagnosticConverter.diagnostic(from: diagnostic)
260+
261+
diagnosticsPerFile[consume sourceFile, default: []].append(consume convertedDiagnostic)
262+
}
161263
}
162264

163-
self.diagnosticsPerFile = consume diagnosticsPerFile
265+
self.diagnosticsPerFile = diagnosticsPerFile
164266
}
165267

166268
package func applyFixIts() throws {
@@ -281,8 +383,8 @@ extension SourceFile: Hashable {
281383
}
282384
}
283385

284-
private struct DiagnosticConverter /*: ~Copyable */ {
285-
private struct SourceFileCache /*: ~Copyable */ {
386+
private struct DiagnosticConverter: ~Copyable {
387+
private struct SourceFileCache: ~Copyable {
286388
private let fileSystem: any FileSystem
287389

288390
private var sourceFiles: [AbsolutePath: SourceFile]
@@ -323,7 +425,7 @@ extension DiagnosticConverter {
323425
// emit notes with those fix-its.
324426
private static func fixIt(
325427
from diagnostic: borrowing some AnyDiagnostic,
326-
in sourceFile: /*borrowing*/ SourceFile
428+
in sourceFile: /* borrowing */ SourceFile
327429
) throws -> SwiftDiagnostics.FixIt {
328430
let changes = try diagnostic.fixIts.map { fixIt in
329431
let startPosition = try sourceFile.position(of: fixIt.start)
@@ -341,7 +443,7 @@ extension DiagnosticConverter {
341443

342444
private static func highlights(
343445
from diagnostic: borrowing some AnyDiagnostic,
344-
in sourceFile: /*borrowing*/ SourceFile
446+
in sourceFile: /* borrowing */ SourceFile
345447
) throws -> [Syntax] {
346448
try diagnostic.ranges.map { startLocation, endLocation in
347449
let startPosition = try sourceFile.position(of: startLocation)
@@ -376,19 +478,19 @@ extension DiagnosticConverter {
376478

377479
mutating func diagnostic(
378480
from diagnostic: borrowing some AnyDiagnostic
379-
) throws -> Diagnostic? {
380-
if diagnostic.fixIts.isEmpty {
481+
) throws -> Diagnostic {
482+
guard !diagnostic.fixIts.isEmpty else {
381483
preconditionFailure("Expected diagnostic with fix-its")
382484
}
383485

384486
guard let location = diagnostic.location else {
385-
return nil
487+
preconditionFailure("Diagnostic without location cannot be converted")
386488
}
387489

388490
let message: DeserializedDiagnosticMessage
389491
do {
390492
guard let severity = SwiftDiagnostics.DiagnosticSeverity(from: diagnostic.level) else {
391-
return nil
493+
preconditionFailure("Diagnostic with 'ignored' severity cannot be converted")
392494
}
393495

394496
let category: SwiftDiagnostics.DiagnosticCategory? =

0 commit comments

Comments
 (0)