Skip to content
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

[WIP] Align pretty-printed diagnostic messages with source code that uses horizontal whitespace characters other than space (U+0020) #167

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
105 changes: 71 additions & 34 deletions Sources/CartonHelpers/Parsers/DiagnosticsParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ public struct DiagnosticsParser: ProcessOutputParser {
struct CustomDiagnostic {
let kind: Kind
let file: String
let line: String.SubSequence
/// The number of the row in the source file that the diagnosis is for.
let lineNumber: Int
let char: String.SubSequence
let code: String
let message: String
Expand Down Expand Up @@ -124,13 +125,11 @@ public struct DiagnosticsParser: ProcessOutputParser {
.replacingOccurrences(of: ":", with: "") == String(currFile)
else { continue }
fileMessages.append(
.init(
kind: CustomDiagnostic
.Kind(rawValue: String(components[2]
.trimmingCharacters(in: .whitespaces))) ??
.note,
CustomDiagnostic(
kind: CustomDiagnostic.Kind(rawValue: String(components[2].trimmingCharacters(in: .whitespaces))) ?? .note,
file: file,
line: components[0],
// FIXME: We should handle this more gracefully than force-unwrapping it.
lineNumber: Int(components[0])!,
char: components[1],
code: String(lines[lineIdx]),
message: components.dropFirst(3).joined(separator: ":")
Expand All @@ -157,14 +156,13 @@ public struct DiagnosticsParser: ProcessOutputParser {
for (file, messages) in diagnostics.sorted(by: { $0.key < $1.key }) {
guard messages.count > 0 else { continue }
terminal.write("\(" \(file) ", color: "[1m", "[7m")") // bold, reversed
terminal.write(" \(messages.first!.file)\(messages.first!.line)\n\n", inColor: .grey)
// Group messages that occur on sequential lines to provie a more readable output
terminal.write(" \(messages.first!.file)\(messages.first!.lineNumber)\n\n", inColor: .grey)
// Group messages that occur on sequential lines to provide a more readable output
var groupedMessages = [[CustomDiagnostic]]()
for message in messages {
if let lastLineStr = groupedMessages.last?.last?.line,
let lastLine = Int(lastLineStr),
let line = Int(message.line),
lastLine == line - 1 || lastLine == line
if let finalLineNumber = groupedMessages.last?.last?.lineNumber,
// `message.lineNumber` is the current line number.
finalLineNumber == message.lineNumber - 1 || finalLineNumber == message.lineNumber
{
groupedMessages[groupedMessages.count - 1].append(message)
} else {
Expand All @@ -180,51 +178,90 @@ public struct DiagnosticsParser: ProcessOutputParser {
" \(" \(kind) ", color: message.kind.color, "[37;1m") \(message.message)\n"
) // 37;1: bright white
}
let maxLine = messages.map(\.line.count).max() ?? 0
let greatestLineNumber = messages.map(\.lineNumber).max() ?? 0
let numberOfDigitsInGreatestLineNumber: Int = {
let (quotient, remainder) = greatestLineNumber.quotientAndRemainder(dividingBy: 10)
return quotient + (remainder == 0 ? 0 : 1)
}()
for (offset, message) in messages.enumerated() {
if offset > 0 {
// Make sure we don't log the same line twice
if messages[offset - 1].line != message.line {
flush(messages: messages, message: message, maxLine: maxLine, terminal)
if messages[offset - 1].lineNumber != message.lineNumber {
flush(
messages: messages,
message: message,
minimumSizeForLineNumbering: numberOfDigitsInGreatestLineNumber,
terminal: terminal
)
}
} else {
flush(messages: messages, message: message, maxLine: maxLine, terminal)
flush(
messages: messages,
message: message,
minimumSizeForLineNumbering: numberOfDigitsInGreatestLineNumber,
terminal: terminal
)
}
}
terminal.write("\n")
}
terminal.write("\n")
}
}


/// <#Description#>
/// - Parameters:
/// - messages: <#messages description#>
/// - message: <#message description#>
/// - minimumSizeForLineNumbering: The minimum space that must be reserved for line numbers, so that they are well-aligned in the output.
/// - terminal: <#terminal description#>
func flush(
messages: [CustomDiagnostic],
message: CustomDiagnostic,
maxLine: Int,
_ terminal: InteractiveWriter
minimumSizeForLineNumbering: Int,
terminal: InteractiveWriter
) {
// Get all diagnostics for a particular line.
let allChars = messages.filter { $0.line == message.line }.map(\.char)
let allChars = messages.filter { $0.lineNumber == message.lineNumber }.map(\.char)
// Output the code for this line, syntax highlighted
let paddedLine = message.line.padding(toLength: maxLine, withPad: " ", startingAt: 0)
let highlightedCode = Self.highlighter.highlight(message.code)
terminal
.write(
" \("\(paddedLine) | ", color: "[36m")\(highlightedCode)\n"
) // 36: cyan
terminal.write(
" " + "".padding(toLength: maxLine, withPad: " ", startingAt: 0) + " | ",
inColor: .cyan
)
/// A base-10 representation of the number of the row that the diagnosis is for, aligned vertically with all other rows.
let verticallyAlignedLineNumber = String(message.lineNumber, radix: 10).padding(toLength: minimumSizeForLineNumbering, withPad: " ", startingAt: 0)
/// The line of code that the diagnostics message is for.
let sourceLine = message.code
// The following 2 assignments remove the leading whitespace from each line of code in the diagnostics.
// Although technically, we should remove only horizontal whitespace characters, but since there is no vertical whitespace in a continuous line, we can safely remove all whitespace characters.
/// The position of the first non-whitespace character in the line of code that the diagnostics message is for.
///
/// If no such character exists, then the position is the same as the line's `endIndex`.
let firstIndexOfNonWhitespaceCharacterInSourceLine = sourceLine.firstIndex(where: { !$0.isWhitespace } ) ?? sourceLine.endIndex
/// The line of code that the diagnostics message is for, with leading whitespace removed.
let sourceLineSansWhitespace = sourceLine[firstIndexOfNonWhitespaceCharacterInSourceLine...]
let highlightedCode = Self.highlighter.highlight(String(sourceLineSansWhitespace))
// Each line of diagnostics output is indented with 2 spaces.
terminal.write(" \(verticallyAlignedLineNumber) | ", inColor: .cyan)
terminal.write(" \(highlightedCode)\n")
terminal.write(" \(String(repeating: " ", count: minimumSizeForLineNumbering)) | ", inColor: .cyan)

// Aggregate the indicators (^ point to the error) onto a single line
var charIndicators = String(repeating: " ", count: Int(message.char)!) + "^"

// Remove leading whitespace.
var charIndicators = String(repeating: " ", count: Int(message.char)! - (sourceLine.count - sourceLineSansWhitespace.count)) + "^"
if allChars.count > 0 {
for char in allChars.dropFirst() {
let idx = Int(char)!
if idx >= charIndicators.count {
charIndicators
.append(String(repeating: " ", count: idx - charIndicators.count) + "^")
for index in charIndicators.count..<idx {
// If the character above the current position is a whitespace character,
// then copy it. If not, then append a space (U+0020).
// Different terminals deal with whitespace characters differently.
// It's better to let the terminal decide how it wants to do,
// so that the "^" and the error location are well-aligned.
charIndicators.append(
sourceLine[sourceLine.index(sourceLine.startIndex, offsetBy: idx)].isWhitespace ?
sourceLine[sourceLine.index(sourceLine.startIndex, offsetBy: idx)] : " "
)
}
charIndicators.append("^")
} else {
var arr = Array(charIndicators)
arr[idx] = "^"
Expand Down
17 changes: 8 additions & 9 deletions Sources/CartonHelpers/Parsers/TestsParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,15 +188,16 @@ public struct TestsParser: ProcessOutputParser {
)
} else if let problem = line.matches(regex: Regex.problem),
let path = line.match(of: Regex.problem, labelled: .path),
let lineNum = line.match(of: Regex.problem, labelled: .line),
let lineNumberInBase10 = line.match(of: Regex.problem, labelled: .line),
let lineNumber = Int(lineNumberInBase10),
let status = line.match(of: Regex.problem, labelled: .status),
let suite = line.match(of: Regex.problem, labelled: .suite),
let testCase = line.match(of: Regex.problem, labelled: .testCase)
{
let diag = DiagnosticsParser.CustomDiagnostic(
kind: DiagnosticsParser.CustomDiagnostic.Kind(rawValue: String(status)) ?? .note,
file: String(path),
line: lineNum,
lineNumber: lineNumber,
char: "0",
code: "",
message: String(problem)
Expand Down Expand Up @@ -255,7 +256,7 @@ public struct TestsParser: ProcessOutputParser {
"\(testCase.name) \("(\(Int(Double(testCase.duration)! * 1000))ms)", color: "[90m")\n"
) // gray
for problem in testCase.problems {
terminal.write("\n \(problem.file, color: "[90m"):\(problem.line)\n")
terminal.write("\n \(problem.file, color: "[90m"):\(problem.lineNumber)\n")
terminal.write(" \(problem.message)\n\n")
// Format XCTAssert functions
for assertion in Regex.Assertion.allCases {
Expand All @@ -268,9 +269,7 @@ public struct TestsParser: ProcessOutputParser {
}
}
// Get the line of code from the file and output it for context.
if let lineNum = Int(problem.line),
lineNum > 0
{
if problem.lineNumber > 0 {
var fileContents: String?
if let fileBuf = fileBufs.first(where: { $0.path == problem.file })?.contents {
fileContents = fileBuf
Expand All @@ -283,9 +282,9 @@ public struct TestsParser: ProcessOutputParser {
}
if let fileContents = fileContents {
let fileLines = fileContents.components(separatedBy: .newlines)
guard fileLines.count >= lineNum else { break }
let highlightedCode = Self.highlighter.highlight(String(fileLines[lineNum - 1]))
terminal.write(" \("\(problem.line) | ", color: "[36m")\(highlightedCode)\n")
guard fileLines.count >= problem.lineNumber else { break }
let highlightedCode = Self.highlighter.highlight(String(fileLines[problem.lineNumber - 1]))
terminal.write(" \("\(problem.lineNumber) | ", color: "[36m")\(highlightedCode)\n")
}
}
}
Expand Down