Skip to content

Commit 3803ca7

Browse files
committed
[Diagnostics formatting] Underline highlighted ranges
When colorizing output, underline highlighted source ranges from diagnostic messages.
1 parent b28bef2 commit 3803ca7

File tree

2 files changed

+153
-1
lines changed

2 files changed

+153
-1
lines changed

Sources/SwiftDiagnostics/DiagnosticsFormatter.swift

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,42 @@
1212

1313
import SwiftSyntax
1414

15+
extension Sequence where Element == Range<Int> {
16+
/// Given a set of ranges that are sorted in order of nondecreasing lower
17+
/// bound, merge any overlapping ranges to produce a sequence of
18+
/// nonoverlapping ranges.
19+
fileprivate func mergingOverlappingRanges() -> [Range<Int>] {
20+
var result: [Range<Int>] = []
21+
22+
var prior: Range<Int>? = nil
23+
for range in self {
24+
// If this is the first range we've seen, note it as the prior and
25+
// continue.
26+
guard let priorRange = prior else {
27+
prior = range
28+
continue
29+
}
30+
31+
// If the ranges overlap, expand the prior range.
32+
if priorRange.overlaps(range) {
33+
let lower = Swift.min(priorRange.lowerBound, range.lowerBound)
34+
let upper = Swift.max(priorRange.upperBound, range.upperBound)
35+
prior = lower..<upper
36+
continue
37+
}
38+
39+
// Append the prior range, then take this new range as the prior
40+
result.append(priorRange)
41+
prior = range
42+
}
43+
44+
if let priorRange = prior {
45+
result.append(priorRange)
46+
}
47+
return result
48+
}
49+
}
50+
1551
public struct DiagnosticsFormatter {
1652

1753
/// A wrapper struct for a source line, its diagnostics, and any
@@ -54,6 +90,94 @@ public struct DiagnosticsFormatter {
5490
return formatter.annotatedSource(tree: tree, diags: diags)
5591
}
5692

93+
/// Colorize the given source line by applying highlights from diagnostics.
94+
private func colorizeSourceLine<SyntaxType: SyntaxProtocol>(
95+
_ annotatedLine: AnnotatedSourceLine,
96+
lineNumber: Int,
97+
tree: SyntaxType,
98+
sourceLocationConverter slc: SourceLocationConverter
99+
) -> String {
100+
guard colorize, !annotatedLine.diagnostics.isEmpty else {
101+
return annotatedLine.sourceString
102+
}
103+
104+
// Compute the set of highlight ranges that land on this line. These
105+
// are column ranges, sorted in order of increasing starting column, and
106+
// with overlapping ranges merged.
107+
let highlightRanges: [Range<Int>] = annotatedLine.diagnostics.map {
108+
$0.highlights
109+
}.joined().compactMap { (highlight) -> Range<Int>? in
110+
if highlight.root != Syntax(tree) {
111+
return nil
112+
}
113+
114+
let startLoc = highlight.startLocation(converter: slc, afterLeadingTrivia: true);
115+
guard let startLine = startLoc.line else {
116+
return nil
117+
}
118+
119+
// Find the starting column.
120+
let startColumn: Int
121+
if startLine < lineNumber {
122+
startColumn = 1
123+
} else if startLine == lineNumber, let column = startLoc.column {
124+
startColumn = column
125+
} else {
126+
return nil
127+
}
128+
129+
// Find the ending column.
130+
let endLoc = highlight.endLocation(converter: slc, afterTrailingTrivia: false)
131+
guard let endLine = endLoc.line else {
132+
return nil
133+
}
134+
135+
let endColumn: Int
136+
if endLine > lineNumber {
137+
endColumn = annotatedLine.sourceString.count
138+
} else if endLine == lineNumber, let column = endLoc.column {
139+
endColumn = column
140+
} else {
141+
return nil
142+
}
143+
144+
if startColumn == endColumn {
145+
return nil
146+
}
147+
148+
return startColumn..<endColumn
149+
}.sorted { (lhs, rhs) in
150+
lhs.lowerBound < rhs.lowerBound
151+
}.mergingOverlappingRanges()
152+
153+
// Map the column ranges into index ranges within the source string itself.
154+
let sourceString = annotatedLine.sourceString
155+
let highlightIndexRanges: [Range<String.Index>] = highlightRanges.map { highlightRange in
156+
let startIndex = sourceString.index(sourceString.startIndex, offsetBy: highlightRange.lowerBound - 1)
157+
let endIndex = sourceString.index(startIndex, offsetBy: highlightRange.count)
158+
return startIndex..<endIndex
159+
}
160+
161+
// Form the annotated string by copying in text from the original source,
162+
// highlighting the column ranges.
163+
var resultSourceString: String = ""
164+
var sourceIndex = sourceString.startIndex
165+
let annotation = ANSIAnnotation.sourceHighlight
166+
for highlightRange in highlightIndexRanges {
167+
// Text before the highlight range
168+
resultSourceString += sourceString[sourceIndex..<highlightRange.lowerBound]
169+
170+
// Highlighted source text
171+
let highlightString = String(sourceString[highlightRange])
172+
resultSourceString += annotation.applied(to: highlightString)
173+
174+
sourceIndex = highlightRange.upperBound
175+
}
176+
177+
resultSourceString += sourceString[sourceIndex...]
178+
return resultSourceString
179+
}
180+
57181
/// Print given diagnostics for a given syntax tree on the command line
58182
///
59183
/// - Parameters:
@@ -136,7 +260,15 @@ public struct DiagnosticsFormatter {
136260
annotatedSource.append(indentString)
137261

138262
// print the source line
139-
annotatedSource.append("\(linePrefix)\(annotatedLine.sourceString)")
263+
annotatedSource.append(linePrefix)
264+
annotatedSource.append(
265+
colorizeSourceLine(
266+
annotatedLine,
267+
lineNumber: lineNumber,
268+
tree: tree,
269+
sourceLocationConverter: slc
270+
)
271+
)
140272

141273
// If the line did not end with \n (e.g. the last line), append it manually
142274
if annotatedSource.last != "\n" {
@@ -279,4 +411,9 @@ struct ANSIAnnotation {
279411
static var bufferOutline: ANSIAnnotation {
280412
ANSIAnnotation(color: .cyan, trait: .normal)
281413
}
414+
415+
/// Annotation used for highlighting source text.
416+
static var sourceHighlight: ANSIAnnotation {
417+
ANSIAnnotation(color: .white, trait: .underline)
418+
}
282419
}

Tests/SwiftDiagnosticsTest/DiagnosticsFormatterTests.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,19 @@ final class DiagnosticsFormatterTests: XCTestCase {
120120
"""
121121
AssertStringsEqualWithDiff(annotate(source: source, colorize: true), expectedOutput)
122122
}
123+
124+
func testColoringWithHighlights() {
125+
let source = """
126+
for (i = 0; i != 10; i += 1) { }
127+
"""
128+
129+
let expectedOutput = """
130+
\u{001B}[0;36m1 │\u{001B}[0;0m for \u{001B}[4;37m(i\u{001B}[0;0m \u{001B}[4;37m= 0; i != 10; i += 1)\u{001B}[0;0m { }
131+
\u{001B}[0;36m∣\u{001B}[0;0m │ ╰─ \u{001B}[1;31merror: expected ')' to end tuple pattern\u{001B}[0;0m
132+
\u{001B}[0;36m∣\u{001B}[0;0m ╰─ \u{001B}[1;31merror: C-style for statement has been removed in Swift 3\u{001B}[0;0m
133+
134+
"""
135+
136+
AssertStringsEqualWithDiff(annotate(source: source, colorize: true), expectedOutput)
137+
}
123138
}

0 commit comments

Comments
 (0)