Skip to content

Added a formatter to pretty print diagnostics #874

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

Merged
merged 1 commit into from
Oct 5, 2022
Merged
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
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ let package = Package(
name: "SwiftBasicFormatTest",
dependencies: ["SwiftBasicFormat"]
),
.testTarget(
name: "SwiftDiagnosticsTest",
dependencies: ["SwiftDiagnostics", "SwiftParser"]
),
.testTarget(
name: "SwiftSyntaxTest",
dependencies: ["SwiftSyntax", "_SwiftSyntaxTestSupport"]
Expand Down
112 changes: 112 additions & 0 deletions Sources/SwiftDiagnostics/DiagnosticsFormatter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//===------------ DiagnosticsFormatter.swift - Formatter for diagnostics ----------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2022 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

import SwiftSyntax

public struct DiagnosticsFormatter {

/// A wrapper struct for a source line and its diagnostics
private struct AnnotatedSourceLine {
var diagnostics: [Diagnostic]
var sourceString: String
}

/// Number of lines which should be printed before and after the diagnostic message
static let contextSize = 2

/// Print given diagnostics for a given syntax tree on the command line
public static func annotatedSource(tree: SourceFileSyntax, diags: [Diagnostic]) -> String {
let slc = SourceLocationConverter(file: "", 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)
}
annotatedSourceLines.append(AnnotatedSourceLine(diagnostics: diagsForLine, sourceString: sourceLine))
}

// Only lines with diagnostic messages should be printed, but including some context
let rangesToPrint = annotatedSourceLines.enumerated().compactMap { (lineIndex, sourceLine) -> Range<Int>? in
let lineNumber = lineIndex + 1
if !sourceLine.diagnostics.isEmpty {
return Range<Int>(uncheckedBounds: (lower: lineNumber - Self.contextSize, upper: lineNumber + Self.contextSize + 1))
}
return nil
}

var annotatedSource = ""

/// Keep track if a line missing char should be printed
var hasLineBeenSkipped = false

let maxNumberOfDigits = String(annotatedSourceLines.count).count

for (lineIndex, annotatedLine) in annotatedSourceLines.enumerated() {
let lineNumber = lineIndex + 1
guard rangesToPrint.contains(where: { range in
range.contains(lineNumber)
}) else {
hasLineBeenSkipped = true
continue
}

// line numbers should be right aligned
let lineNumberString = String(lineNumber)
let leadingSpaces = String(repeating: " ", count: maxNumberOfDigits - lineNumberString.count)
let linePrefix = "\(leadingSpaces)\(lineNumberString) │ "

// If necessary, print a line that indicates that there was lines skipped in the source code
if hasLineBeenSkipped && !annotatedSource.isEmpty {
let lineMissingInfoLine = String(repeating: " ", count: maxNumberOfDigits) + " ┆"
annotatedSource.append("\(lineMissingInfoLine)\n")
}
hasLineBeenSkipped = false

// print the source line
annotatedSource.append("\(linePrefix)\(annotatedLine.sourceString)")

// If the line did not end with \n (e.g. the last line), append it manually
if annotatedSource.last != "\n" {
annotatedSource.append("\n")
}

let columnsWithDiagnostics = Set(annotatedLine.diagnostics.map { $0.location(converter: slc).column ?? 0 })
let diagsPerColumn = Dictionary(grouping: annotatedLine.diagnostics) { diag in
diag.location(converter: slc).column ?? 0
}.sorted { lhs, rhs in
lhs.key > rhs.key
}

for (column, diags) in diagsPerColumn {
// compute the string that is shown before each message
var preMessage = String(repeating: " ", count: maxNumberOfDigits) + " ∣"
for c in 0..<column {
if columnsWithDiagnostics.contains(c) {
preMessage.append("│")
} else {
preMessage.append(" ")
}
}

for diag in diags.dropLast(1) {
annotatedSource.append("\(preMessage)├─ \(diag.message)\n")
}
annotatedSource.append("\(preMessage)╰─ \(diags.last!.message)\n")

}
}
return annotatedSource
}
}
29 changes: 29 additions & 0 deletions Sources/SwiftSyntax/SourceLocation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ public struct SourceRange: Hashable, Codable, CustomDebugStringConvertible {
/// part of the same tree that was used to initialize this class.
public final class SourceLocationConverter {
let file: String
/// The source of the file, modelled as data so it can contain invalid UTF-8.
let source: [UInt8]
/// Array of lines and the position at the start of the line.
let lines: [AbsolutePosition]
/// Position at end of file.
Expand All @@ -125,6 +127,7 @@ public final class SourceLocationConverter {
public init<SyntaxType: SyntaxProtocol>(file: String, tree: SyntaxType) {
assert(tree.parent == nil, "SourceLocationConverter must be passed the root of the syntax tree")
self.file = file
self.source = tree.syntaxTextBytes
(self.lines, endOfFile) = computeLines(tree: Syntax(tree))
assert(tree.byteSize == endOfFile.utf8Offset)
}
Expand All @@ -134,9 +137,35 @@ public final class SourceLocationConverter {
/// - source: The source code to convert positions to line/columns for.
public init(file: String, source: String) {
self.file = file
self.source = Array(source.utf8)
(self.lines, endOfFile) = computeLines(source)
assert(source.utf8.count == endOfFile.utf8Offset)
}

/// Execute the body with an array that contains each source line.
func withSourceLines<T>(_ body: ([SyntaxText]) throws -> T) rethrows -> T {
return try source.withUnsafeBufferPointer { (sourcePointer: UnsafeBufferPointer<UInt8>) in
var result: [SyntaxText] = []
var previousLoc = AbsolutePosition.startOfFile
assert(lines.first == AbsolutePosition.startOfFile)
for lineStartLoc in lines.dropFirst() + [endOfFile] {
result.append(SyntaxText(
baseAddress: sourcePointer.baseAddress?.advanced(by: previousLoc.utf8Offset),
count: lineStartLoc.utf8Offset - previousLoc.utf8Offset
))
previousLoc = lineStartLoc
}
return try body(result)
}
}

/// Return the source lines of the file as `String`s.
/// Because `String` cannot model invalid UTF-8, the concatenation of these source lines might not be source-accurate in case there are Unicode errors in the source file, but for most practical purposes, this should not pose an issue.
public var sourceLines: [String] {
return withSourceLines { syntaxTextLines in
return syntaxTextLines.map { String(syntaxText: $0) }
}
}

/// Convert a `AbsolutePosition` to a `SourceLocation`. If the position is
/// exceeding the file length then the `SourceLocation` for the end of file
Expand Down
7 changes: 3 additions & 4 deletions Sources/swift-parser-test/swift-parser-test.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,18 +163,17 @@ class PrintDiags: ParsableCommand {
languageVersion: swiftVersion,
enableBareSlashRegexLiteral: enableBareSlashRegex
)

var diags = ParseDiagnosticsGenerator.diagnostics(for: tree)

print(DiagnosticsFormatter.annotatedSource(tree: tree, diags: diags))

if foldSequences {
diags += foldAllSequences(tree).1
}

if diags.isEmpty {
print("No diagnostics produced")
}
for diag in diags {
print("\(diag.debugDescription)")
}
}
}
}
Expand Down
81 changes: 81 additions & 0 deletions Tests/SwiftDiagnosticsTest/DiagnosticsFormatterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//===------------------ DiagnosticsFormatterTests.swift -------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2022 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//
import XCTest
import SwiftDiagnostics
import SwiftParser

final class DiagnosticsFormatterTests: XCTestCase {

func annotate(source: String) throws -> String {
let tree = try Parser.parse(source: source)
let diags = ParseDiagnosticsGenerator.diagnostics(for: tree)
return DiagnosticsFormatter.annotatedSource(tree: tree, diags: diags)
}

func testSingleDiagnostic() throws {
let source = """
var foo = bar +
"""
let expectedOutput = """
1 │ var foo = bar +
∣ ╰─ expected expression in variable

"""
XCTAssertEqual(expectedOutput, try annotate(source: source))
}

func testMultipleDiagnosticsInOneLine() throws {
let source = """
foo.[].[].[]
"""
let expectedOutput = """
1 │ foo.[].[].[]
∣ │ │ ╰─ expected identifier in member access
∣ │ ╰─ expected identifier in member access
∣ ╰─ expected identifier in member access

"""
XCTAssertEqual(expectedOutput, try annotate(source: source))
}

func testLineSkipping() throws {
let source = """
var i = 1
i = 2
i = foo(
i = 4
i = 5
i = 6
i = 7
i = 8
i = 9
i = 10
i = bar(
"""
let expectedOutput = """
2 │ i = 2
3 │ i = foo(
4 │ i = 4
∣ ╰─ expected ')' to end function call
5 │ i = 5
6 │ i = 6
9 │ i = 9
10 │ i = 10
11 │ i = bar(
∣ ├─ expected value in function call
∣ ╰─ expected ')' to end function call

"""
XCTAssertEqual(expectedOutput, try annotate(source: source))
}
}