Skip to content

Commit 34c67ee

Browse files
committed
Added a formatter for the diagnostics
Update DiagnosticsFormatter.swift Use correct fileheader typo simplify if statement Applied QA Remove old diagnostics printing Applied QA Remove superfluous line Update Sources/SwiftDiagnostics/DiagnosticsFormatter.swift Co-authored-by: Alex Hoppen <[email protected]> Add some test cases Remove unnecessary @spi_
1 parent 70eaab1 commit 34c67ee

File tree

5 files changed

+229
-4
lines changed

5 files changed

+229
-4
lines changed

Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ let package = Package(
151151
name: "SwiftBasicFormatTest",
152152
dependencies: ["SwiftBasicFormat"]
153153
),
154+
.testTarget(
155+
name: "SwiftDiagnosticsTest",
156+
dependencies: ["SwiftDiagnostics", "SwiftParser"]
157+
),
154158
.testTarget(
155159
name: "SwiftSyntaxTest",
156160
dependencies: ["SwiftSyntax", "_SwiftSyntaxTestSupport"]
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
//===------------ DiagnosticsFormatter.swift - Formatter for diagnostics ----------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2022 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import SwiftSyntax
14+
15+
public struct DiagnosticsFormatter {
16+
17+
/// A wrapper struct for a source line and its diagnostics
18+
private struct AnnotatedSourceLine {
19+
var diagnostics: [Diagnostic]
20+
var sourceString: String
21+
}
22+
23+
/// Number of lines which should be printed before and after the diagnostic message
24+
static let contextSize = 2
25+
26+
/// Print given diagnostics for a given syntax tree on the command line
27+
public static func annotatedSource(tree: SourceFileSyntax, diags: [Diagnostic]) -> String {
28+
let slc = SourceLocationConverter(file: "", tree: tree)
29+
30+
// First, we need to put each line and its diagnostics together
31+
var annotatedSourceLines = [AnnotatedSourceLine]()
32+
33+
for (sourceLineIndex, sourceLine) in slc.sourceLines.enumerated() {
34+
let diagsForLine = diags.filter { diag in
35+
return diag.location(converter: slc).line == (sourceLineIndex + 1)
36+
}
37+
annotatedSourceLines.append(AnnotatedSourceLine(diagnostics: diagsForLine, sourceString: sourceLine))
38+
}
39+
40+
// Only lines with diagnostic messages should be printed, but including some context
41+
let rangesToPrint = annotatedSourceLines.enumerated().compactMap { (lineIndex, sourceLine) -> Range<Int>? in
42+
let lineNumber = lineIndex + 1
43+
if !sourceLine.diagnostics.isEmpty {
44+
return Range<Int>(uncheckedBounds: (lower: lineNumber - Self.contextSize, upper: lineNumber + Self.contextSize + 1))
45+
}
46+
return nil
47+
}
48+
49+
var annotatedSource = ""
50+
51+
/// Keep track if a line missing char should be printed
52+
var hasLineBeenSkipped = false
53+
54+
let maxNumberOfDigits = String(annotatedSourceLines.count).count
55+
56+
for (lineIndex, annotatedLine) in annotatedSourceLines.enumerated() {
57+
let lineNumber = lineIndex + 1
58+
guard rangesToPrint.contains(where: { range in
59+
range.contains(lineNumber)
60+
}) else {
61+
hasLineBeenSkipped = true
62+
continue
63+
}
64+
65+
// line numbers should be right aligned
66+
let lineNumberString = String(lineNumber)
67+
let leadingSpaces = String(repeating: " ", count: maxNumberOfDigits - lineNumberString.count)
68+
let linePrefix = "\(leadingSpaces)\(lineNumberString)"
69+
70+
// If necessary, print a line that indicates that there was lines skipped in the source code
71+
if hasLineBeenSkipped && !annotatedSource.isEmpty {
72+
let lineMissingInfoLine = String(repeating: " ", count: maxNumberOfDigits) + ""
73+
annotatedSource.append("\(lineMissingInfoLine)\n")
74+
}
75+
hasLineBeenSkipped = false
76+
77+
// print the source line
78+
annotatedSource.append("\(linePrefix)\(annotatedLine.sourceString)")
79+
80+
// If the line did not end with \n (e.g. the last line), append it manually
81+
if annotatedSource.last != "\n" {
82+
annotatedSource.append("\n")
83+
}
84+
85+
let columnsWithDiagnostics = Set(annotatedLine.diagnostics.map { $0.location(converter: slc).column ?? 0 })
86+
let diagsPerColumn = Dictionary(grouping: annotatedLine.diagnostics) { diag in
87+
diag.location(converter: slc).column ?? 0
88+
}.sorted { lhs, rhs in
89+
lhs.key > rhs.key
90+
}
91+
92+
for (column, diags) in diagsPerColumn {
93+
// compute the string that is shown before each message
94+
var preMessage = String(repeating: " ", count: maxNumberOfDigits) + ""
95+
for c in 0..<column {
96+
if columnsWithDiagnostics.contains(c) {
97+
preMessage.append("")
98+
} else {
99+
preMessage.append(" ")
100+
}
101+
}
102+
103+
for diag in diags.dropLast(1) {
104+
annotatedSource.append("\(preMessage)├─ \(diag.message)\n")
105+
}
106+
annotatedSource.append("\(preMessage)╰─ \(diags.last!.message)\n")
107+
108+
}
109+
}
110+
return annotatedSource
111+
}
112+
}

Sources/SwiftSyntax/SourceLocation.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ public struct SourceRange: Hashable, Codable, CustomDebugStringConvertible {
114114
/// part of the same tree that was used to initialize this class.
115115
public final class SourceLocationConverter {
116116
let file: String
117+
/// The source of the file, modelled as data so it can contain invalid UTF-8.
118+
let source: [UInt8]
117119
/// Array of lines and the position at the start of the line.
118120
let lines: [AbsolutePosition]
119121
/// Position at end of file.
@@ -125,6 +127,7 @@ public final class SourceLocationConverter {
125127
public init<SyntaxType: SyntaxProtocol>(file: String, tree: SyntaxType) {
126128
assert(tree.parent == nil, "SourceLocationConverter must be passed the root of the syntax tree")
127129
self.file = file
130+
self.source = tree.syntaxTextBytes
128131
(self.lines, endOfFile) = computeLines(tree: Syntax(tree))
129132
assert(tree.byteSize == endOfFile.utf8Offset)
130133
}
@@ -134,9 +137,35 @@ public final class SourceLocationConverter {
134137
/// - source: The source code to convert positions to line/columns for.
135138
public init(file: String, source: String) {
136139
self.file = file
140+
self.source = Array(source.utf8)
137141
(self.lines, endOfFile) = computeLines(source)
138142
assert(source.utf8.count == endOfFile.utf8Offset)
139143
}
144+
145+
/// Execute the body with an array that contains each source line.
146+
func withSourceLines<T>(_ body: ([SyntaxText]) throws -> T) rethrows -> T {
147+
return try source.withUnsafeBufferPointer { (sourcePointer: UnsafeBufferPointer<UInt8>) in
148+
var result: [SyntaxText] = []
149+
var previousLoc = AbsolutePosition.startOfFile
150+
assert(lines.first == AbsolutePosition.startOfFile)
151+
for lineStartLoc in lines.dropFirst() + [endOfFile] {
152+
result.append(SyntaxText(
153+
baseAddress: sourcePointer.baseAddress?.advanced(by: previousLoc.utf8Offset),
154+
count: lineStartLoc.utf8Offset - previousLoc.utf8Offset
155+
))
156+
previousLoc = lineStartLoc
157+
}
158+
return try body(result)
159+
}
160+
}
161+
162+
/// Return the source lines of the file as `String`s.
163+
/// 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.
164+
public var sourceLines: [String] {
165+
return withSourceLines { syntaxTextLines in
166+
return syntaxTextLines.map { String(syntaxText: $0) }
167+
}
168+
}
140169

141170
/// Convert a `AbsolutePosition` to a `SourceLocation`. If the position is
142171
/// exceeding the file length then the `SourceLocation` for the end of file

Sources/swift-parser-test/swift-parser-test.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,18 +163,17 @@ class PrintDiags: ParsableCommand {
163163
languageVersion: swiftVersion,
164164
enableBareSlashRegexLiteral: enableBareSlashRegex
165165
)
166+
166167
var diags = ParseDiagnosticsGenerator.diagnostics(for: tree)
167-
168+
print(DiagnosticsFormatter.annotatedSource(tree: tree, diags: diags))
169+
168170
if foldSequences {
169171
diags += foldAllSequences(tree).1
170172
}
171173

172174
if diags.isEmpty {
173175
print("No diagnostics produced")
174176
}
175-
for diag in diags {
176-
print("\(diag.debugDescription)")
177-
}
178177
}
179178
}
180179
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//===------------------ DiagnosticsFormatterTests.swift -------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2022 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
import XCTest
13+
import SwiftDiagnostics
14+
import SwiftParser
15+
16+
final class DiagnosticsFormatterTests: XCTestCase {
17+
18+
func annotate(source: String) throws -> String {
19+
let tree = try Parser.parse(source: source)
20+
let diags = ParseDiagnosticsGenerator.diagnostics(for: tree)
21+
return DiagnosticsFormatter.annotatedSource(tree: tree, diags: diags)
22+
}
23+
24+
func testSingleDiagnostic() throws {
25+
let source = """
26+
var foo = bar +
27+
"""
28+
let expectedOutput = """
29+
1 │ var foo = bar +
30+
∣ ╰─ expected expression in variable
31+
32+
"""
33+
XCTAssertEqual(expectedOutput, try annotate(source: source))
34+
}
35+
36+
func testMultipleDiagnosticsInOneLine() throws {
37+
let source = """
38+
foo.[].[].[]
39+
"""
40+
let expectedOutput = """
41+
1 │ foo.[].[].[]
42+
∣ │ │ ╰─ expected identifier in member access
43+
∣ │ ╰─ expected identifier in member access
44+
∣ ╰─ expected identifier in member access
45+
46+
"""
47+
XCTAssertEqual(expectedOutput, try annotate(source: source))
48+
}
49+
50+
func testLineSkipping() throws {
51+
let source = """
52+
var i = 1
53+
i = 2
54+
i = foo(
55+
i = 4
56+
i = 5
57+
i = 6
58+
i = 7
59+
i = 8
60+
i = 9
61+
i = 10
62+
i = bar(
63+
"""
64+
let expectedOutput = """
65+
2 │ i = 2
66+
3 │ i = foo(
67+
4 │ i = 4
68+
∣ ╰─ expected ')' to end function call
69+
5 │ i = 5
70+
6 │ i = 6
71+
72+
9 │ i = 9
73+
10 │ i = 10
74+
11 │ i = bar(
75+
∣ ├─ expected value in function call
76+
∣ ╰─ expected ')' to end function call
77+
78+
"""
79+
XCTAssertEqual(expectedOutput, try annotate(source: source))
80+
}
81+
}

0 commit comments

Comments
 (0)