Skip to content

[Diagnostics] Make the node and position of Diagnostic and Note optional #3038

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions Sources/SwiftCompilerPluginMessageHandling/Diagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@ extension PluginMessage.Diagnostic.Severity {

extension PluginMessage.Diagnostic {
init(from syntaxDiag: SwiftDiagnostics.Diagnostic, in sourceManager: SourceManager) {
if let position = sourceManager.position(
of: syntaxDiag.node,
at: .afterLeadingTrivia
) {
if let node = syntaxDiag.node,
let position = sourceManager.position(
of: node,
at: .afterLeadingTrivia
)
{
self.position = .init(fileName: position.fileName, offset: position.utf8Offset)
} else {
self.position = .invalid
Expand All @@ -92,7 +94,7 @@ extension PluginMessage.Diagnostic {
}

self.notes = syntaxDiag.notes.compactMap {
guard let pos = sourceManager.position(of: $0.node, at: .afterLeadingTrivia) else {
guard let node = $0.node, let pos = sourceManager.position(of: node, at: .afterLeadingTrivia) else {
return nil
}
let position = PluginMessage.Diagnostic.Position(
Expand Down
27 changes: 19 additions & 8 deletions Sources/SwiftDiagnostics/Diagnostic.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ public struct Diagnostic: CustomDebugStringConvertible, Sendable {
public let diagMessage: DiagnosticMessage

/// The node at whose start location the message should be displayed.
public let node: Syntax
public let node: Syntax?

/// The position at which the location should be anchored.
/// By default, this is the start location of `node`.
public let position: AbsolutePosition
public let position: AbsolutePosition?

/// Nodes that should be highlighted in the source code.
public let highlights: [Syntax]
Expand All @@ -40,17 +40,17 @@ public struct Diagnostic: CustomDebugStringConvertible, Sendable {
/// If `highlights` is `nil` then `node` will be highlighted. This is a
/// reasonable default for almost all diagnostics.
public init(
node: some SyntaxProtocol,
node: (some SyntaxProtocol)?,
position: AbsolutePosition? = nil,
message: DiagnosticMessage,
highlights: [Syntax]? = nil,
notes: [Note] = [],
fixIts: [FixIt] = []
) {
self.node = Syntax(node)
self.position = position ?? node.positionAfterSkippingLeadingTrivia
self.node = node.map { Syntax($0) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have a Syntax initializer that just proposes nil, so it should be possible to leave this as-is.

self.position = position ?? node?.positionAfterSkippingLeadingTrivia
self.diagMessage = message
self.highlights = highlights ?? [Syntax(node)]
self.highlights = highlights ?? node.map { [Syntax($0)] } ?? []
self.notes = notes
self.fixIts = fixIts
}
Expand All @@ -67,13 +67,24 @@ public struct Diagnostic: CustomDebugStringConvertible, Sendable {
}

/// The location at which the diagnostic should be displayed.
public func location(converter: SourceLocationConverter) -> SourceLocation {
public func location(converter: SourceLocationConverter) -> SourceLocation? {
guard let position else {
return nil
}

return converter.location(for: position)
}

public var debugDescription: String {
guard let node else {
return "\(DiagnosticsFormatter.unknownFileName):0:0: \(message)"
}

let locationConverter = SourceLocationConverter(fileName: "", tree: node.root)
let location = location(converter: locationConverter)
guard let location = location(converter: locationConverter) else {
return "\(DiagnosticsFormatter.unknownFileName):0:0: \(message)"
}

return "\(location.line):\(location.column): \(message)"
}
}
Expand Down
20 changes: 15 additions & 5 deletions Sources/SwiftDiagnostics/DiagnosticsFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ extension Sequence where Element == Range<Int> {
}

public struct DiagnosticsFormatter {
/// The string used to identify an unknown file name.
@_spi(Testing)
public static let unknownFileName = "<unknown>"

/// A wrapper struct for a source line, its diagnostics, and any
/// non-diagnostic text that follows the line.
Expand Down Expand Up @@ -215,14 +218,19 @@ public struct DiagnosticsFormatter {
suffixTexts: [AbsolutePosition: String],
sourceLocationConverter: SourceLocationConverter? = nil
) -> String {
let slc = sourceLocationConverter ?? SourceLocationConverter(fileName: "<unknown>", tree: tree)
let slc =
sourceLocationConverter ?? SourceLocationConverter(fileName: DiagnosticsFormatter.unknownFileName, tree: tree)

// First, we need to put each line and its diagnostics together
var annotatedSourceLines = [AnnotatedSourceLine]()

for (sourceLineIndex, sourceLine) in slc.sourceLines.enumerated() {
let diagsForLine = diags.filter { diag in
return diag.location(converter: slc).line == (sourceLineIndex + 1)
guard let location = diag.location(converter: slc) else {
return false
}

return location.line == (sourceLineIndex + 1)
}
let suffixText = suffixTexts.compactMap { (position, text) in
if slc.location(for: position).line == (sourceLineIndex + 1) {
Expand Down Expand Up @@ -299,12 +307,14 @@ public struct DiagnosticsFormatter {
}

let columnsWithDiagnostics = Set(
annotatedLine.diagnostics.map {
annotatedLine.characterColumn(ofUtf8Column: $0.location(converter: slc).column)
annotatedLine.diagnostics.compactMap {
$0.location(converter: slc).map {
annotatedLine.characterColumn(ofUtf8Column: $0.column)
}
Comment on lines +311 to +313
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it’s just me but I find Optional.map always hard to read and would prefer.

Suggested change
$0.location(converter: slc).map {
annotatedLine.characterColumn(ofUtf8Column: $0.column)
}
if guard let location = $0.location(converter: slc) else {
return nil
}
return annotatedLine.characterColumn(ofUtf8Column: $0.column)

}
)
let diagsPerColumn = Dictionary(grouping: annotatedLine.diagnostics) { diag in
annotatedLine.characterColumn(ofUtf8Column: diag.location(converter: slc).column)
annotatedLine.characterColumn(ofUtf8Column: diag.location(converter: slc)!.column)
}.sorted { lhs, rhs in
lhs.key > rhs.key
}
Expand Down
50 changes: 42 additions & 8 deletions Sources/SwiftDiagnostics/GroupedDiagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ public struct GroupedDiagnostics {
/// source file IDs.
var rootIndexes: [Syntax: SourceFileID] = [:]

/// Diagnostics that aren't associated with any source file because they have
/// no location information.
var floatingDiagnostics: [Diagnostic] = []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of introducing a new term floating here, how about calling this something like unknownPositionDiagnostics?


public init() {}

/// Add a new source file to the set of grouped diagnostics.
Expand Down Expand Up @@ -122,8 +126,16 @@ public struct GroupedDiagnostics {
_ diagnostic: Diagnostic,
in knownSourceFileID: SourceFileID? = nil
) {
guard let sourceFileID = knownSourceFileID ?? findSourceFileContaining(diagnostic.node) else {
// Drop the diagnostic on the floor.
guard let node = diagnostic.node else {
// There is no node anchoring the diagnostic in a source file, so treat
// it as floating.
floatingDiagnostics.append(diagnostic)
return
}

guard let sourceFileID = knownSourceFileID ?? findSourceFileContaining(node) else {
// This diagnostic is in source file, but we aren't producing diagnostics
// for that file. Drop the diagnostic on the floor.
return
}

Expand Down Expand Up @@ -205,7 +217,9 @@ extension GroupedDiagnostics {
// If there's a primary diagnostic, print it first.
if let (primaryDiagSourceFile, primaryDiag) = findPrimaryDiagnostic(in: sourceFile) {
let primaryDiagSLC = primaryDiagSourceFile.sourceLocationConverter
let location = primaryDiag.location(converter: primaryDiagSLC)
let location =
primaryDiag.location(converter: primaryDiagSLC)
?? SourceLocation(line: 0, column: 0, offset: 0, file: DiagnosticsFormatter.unknownFileName)

// Display file/line/column and diagnostic text for the primary diagnostic.
prefixString =
Expand Down Expand Up @@ -233,9 +247,13 @@ extension GroupedDiagnostics {
prefixString += "`- \(bufferLoc.file):\(bufferLoc.line):\(bufferLoc.column): \(decoratedMessage)\n"
}
}
} else if let firstDiag = sourceFile.diagnostics.first,
let firstDiagLoc = firstDiag.location(converter: slc)
{
prefixString = "\(sourceFile.displayName): \(firstDiagLoc.line):"
} else {
let firstLine = sourceFile.diagnostics.first.map { $0.location(converter: slc).line } ?? 0
prefixString = "\(sourceFile.displayName): \(firstLine):"
// There are no diagnostics to print from this file.
return ""
}

suffixString = ""
Expand Down Expand Up @@ -275,9 +293,25 @@ extension GroupedDiagnostics {
extension DiagnosticsFormatter {
/// Annotate all of the source files in the given set of grouped diagnostics.
public func annotateSources(in group: GroupedDiagnostics) -> String {
return group.rootSourceFiles.map { rootSourceFileID in
group.annotateSource(rootSourceFileID, formatter: self, indentString: "")
}.joined(separator: "\n")
// Render all grouped diagnostics.
var renderedDiags = group.rootSourceFiles.compactMap { rootSourceFileID in
let annotated = group.annotateSource(
rootSourceFileID,
formatter: self,
indentString: ""
)
return annotated.isEmpty ? nil : annotated
}

// Render any floating diagnostics, which have no anchor.
renderedDiags.append(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opinionated question: Should we render the floating diagnostics before or after the diagnostics with source locations? I would have rendered them before because I visualize them on the first line.

Also, using += instead of append(contentsOf:) would remove a nesting level here.

contentsOf: group.floatingDiagnostics.map { diag in
let renderedMessage = diagnosticDecorator.decorateDiagnosticMessage(diag.diagMessage)
return "\(DiagnosticsFormatter.unknownFileName):0:0: \(renderedMessage)"
}
)

return renderedDiags.joined(separator: "\n")
}

public static func annotateSources(
Expand Down
25 changes: 15 additions & 10 deletions Sources/SwiftDiagnostics/Note.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,22 @@ extension NoteMessage {
/// A note that points to another node that's relevant for a Diagnostic.
public struct Note: CustomDebugStringConvertible, Sendable {
/// The node whose location the node is pointing.
public let node: Syntax
public let node: Syntax?

/// The position at which the location should be anchored.
/// By default, this is the start location of `node`.
public let position: AbsolutePosition
public let position: AbsolutePosition?

/// A description of what this note is pointing at.
public let noteMessage: NoteMessage

public init(
node: Syntax,
node: Syntax?,
position: AbsolutePosition? = nil,
message: NoteMessage
) {
self.node = node
self.position = position ?? node.positionAfterSkippingLeadingTrivia
self.position = position ?? node?.positionAfterSkippingLeadingTrivia
self.noteMessage = message
}

Expand All @@ -62,17 +62,22 @@ public struct Note: CustomDebugStringConvertible, Sendable {
}

/// The location at which the note should be displayed.
public func location(converter: SourceLocationConverter) -> SourceLocation {
public func location(converter: SourceLocationConverter) -> SourceLocation? {
guard let position else {
return nil
}

return converter.location(for: position)
}

public var debugDescription: String {
if let root = node.root.as(SourceFileSyntax.self) {
if let root = node?.root.as(SourceFileSyntax.self) {
let locationConverter = SourceLocationConverter(fileName: "", tree: root)
let location = location(converter: locationConverter)
return "\(location): \(message)"
} else {
return "<unknown>: \(message)"
if let location = location(converter: locationConverter) {
return "\(location): \(message)"
}
}

return "\(DiagnosticsFormatter.unknownFileName):0:0: \(message)"
}
}
29 changes: 23 additions & 6 deletions Sources/SwiftParserDiagnostics/ParseDiagnosticsGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,19 +107,27 @@ public class ParseDiagnosticsGenerator: SyntaxAnyVisitor {
let diagProducer = ParseDiagnosticsGenerator()
diagProducer.walk(tree)
diagProducer.diagnostics.sort {
if $0.position != $1.position {
return $0.position < $1.position
guard let lhsPosition = $0.position, let rhsPosition = $1.position else {
return $0.position == nil
}

if lhsPosition != rhsPosition {
return lhsPosition < rhsPosition
}

guard let lhsNode = $0.node, let rhsNode = $1.node else {
return $0.node == nil
}

// Emit children diagnostics before parent diagnostics.
// This makes sure that for missing declarations with attributes, we emit diagnostics on the attribute before we complain about the missing declaration.
if $0.node.hasParent($1.node) {
if lhsNode.hasParent(rhsNode) {
return true
} else if $1.node.hasParent($0.node) {
} else if rhsNode.hasParent(lhsNode) {
return false
} else {
// If multiple tokens are missing at the same location, emit diagnostics about nodes that occur earlier in the tree first.
return $0.node.id < $1.node.id
return lhsNode.id < rhsNode.id
}
}
return diagProducer.diagnostics
Expand Down Expand Up @@ -157,7 +165,16 @@ public class ParseDiagnosticsGenerator: SyntaxAnyVisitor {
if suppressRemainingDiagnostics {
return
}
diagnostics.removeAll(where: { handledNodes.contains($0.node.id) })

diagnostics.removeAll(
where: {
if let nodeId = $0.node?.id {
return handledNodes.contains(nodeId)
}

return false
}
)
diagnostics.append(diagnostic)
self.handledNodes.append(contentsOf: handledNodes)
}
Expand Down
27 changes: 25 additions & 2 deletions Sources/SwiftSyntaxMacrosGenericTestSupport/Assertions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,18 @@ func assertNote(
location: spec.failureLocation.underlying,
failureHandler: { failureHandler(TestFailureSpec(underlying: $0)) }
)
let location = expansionContext.location(for: note.position, anchoredAt: note.node, fileName: "")
guard let position = note.position, let node = note.node else {
failureHandler(
TestFailureSpec(
message: "note has no position",
location: spec.failureLocation
)
)

return
}

let location = expansionContext.location(for: position, anchoredAt: node, fileName: "")
if location.line != spec.line {
failureHandler(
TestFailureSpec(
Expand Down Expand Up @@ -387,7 +398,19 @@ public func assertDiagnostic(
location: spec.failureLocation.underlying,
failureHandler: { failureHandler(TestFailureSpec(underlying: $0)) }
)
let location = expansionContext.location(for: diag.position, anchoredAt: diag.node, fileName: "")
guard let position = diag.position,
let node = diag.node
else {
failureHandler(
TestFailureSpec(
message: "diagnostic missing location info",
location: spec.failureLocation
)
)
return
}

let location = expansionContext.location(for: position, anchoredAt: node, fileName: "")
if location.line != spec.line {
failureHandler(
TestFailureSpec(
Expand Down
Loading