Pretty print diagnostics (#122)

This commit is contained in:
Carson Katri 2020-10-14 07:26:31 -04:00 committed by GitHub
parent 95cb7d5086
commit 97a7e4fbac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 307 additions and 10 deletions

View File

@ -46,6 +46,15 @@
"version": "4.2.0"
}
},
{
"package": "Splash",
"repositoryURL": "https://github.com/JohnSundell/Splash.git",
"state": {
"branch": null,
"revision": "f25dd8c9f16be1f81a152f6861d7216c3c9302da",
"version": "0.14.0"
}
},
{
"package": "swift-argument-parser",
"repositoryURL": "https://github.com/apple/swift-argument-parser",

View File

@ -25,6 +25,7 @@ let package = Package(
.package(url: "https://github.com/OpenCombine/OpenCombine.git", from: "0.10.0"),
.package(url: "https://github.com/vapor/vapor.git", from: "4.29.3"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "1.1.0"),
.package(url: "https://github.com/JohnSundell/Splash.git", from: "0.14.0"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module
@ -58,6 +59,7 @@ let package = Package(
.product(name: "AsyncHTTPClient", package: "async-http-client"),
.product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"),
openCombineProduct,
"Splash",
]
),
// This target is used only for release automation tasks and

View File

@ -19,6 +19,7 @@ let package = Package(
.package(url: "https://github.com/OpenCombine/OpenCombine.git", from: "0.10.0"),
.package(url: "https://github.com/vapor/vapor.git", from: "4.29.3"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "1.1.0"),
.package(url: "https://github.com/JohnSundell/Splash.git", from: "0.14.0"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module
@ -52,6 +53,7 @@ let package = Package(
.product(name: "AsyncHTTPClient", package: "async-http-client"),
.product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"),
"OpenCombine",
"Splash",
]
),
// This target is used only for release automation tasks and

View File

@ -0,0 +1,261 @@
// Copyright 2020 Carton contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import Splash
import TSCBasic
private extension StringProtocol {
func matches(regex: NSRegularExpression) -> String.SubSequence? {
let str = String(self)
guard let range = str.range(of: regex),
range.upperBound < str.endIndex
else { return nil }
return str[range.upperBound..<str.endIndex]
}
func range(of regex: NSRegularExpression) -> Range<String.Index>? {
let str = String(self)
let range = NSRange(location: 0, length: utf16.count)
guard let match = regex.firstMatch(in: str, options: [], range: range),
let matchRange = Range(match.range, in: str)
else {
return nil
}
return matchRange
}
}
private extension String.StringInterpolation {
mutating func appendInterpolation<T>(_ value: T, color: String...) {
appendInterpolation("\(color.map { "\u{001B}\($0)" }.joined())\(value)\u{001B}[0m")
}
}
private extension TokenType {
var color: String {
// Reference on escape codes: https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
switch self {
case .keyword: return "[35;1m" // magenta;bold
case .comment: return "[90m" // bright black
case .call, .dotAccess, .property, .type: return "[94m" // bright blue
case .number, .preprocessing: return "[33m" // yellow
case .string: return "[91;1m" // bright red;bold
default: return "[0m" // reset
}
}
}
private struct TerminalOutputFormat: OutputFormat {
func makeBuilder() -> TerminalOutputBuilder {
.init()
}
struct TerminalOutputBuilder: OutputBuilder {
var output: String = ""
mutating func addToken(_ token: String, ofType type: TokenType) {
output.append("\(token, color: type.color)")
}
mutating func addPlainText(_ text: String) {
output.append(text)
}
mutating func addWhitespace(_ whitespace: String) {
output.append(whitespace)
}
mutating func build() -> String {
output
}
}
}
/// Parses and re-formats diagnostics output by the Swift compiler.
///
/// The compiler output often repeats iteself, and the diagnostics can sometimes be
/// difficult to read.
/// This reformats them to a more readable output.
struct DiagnosticsParser {
// swiftlint:disable force_try
enum Regex {
/// The output has moved to a new file
static let enterFile = try! NSRegularExpression(pattern: #"\[\d+\/\d+\] Compiling \w+ "#)
/// A message is beginning with the line # following the `:`
static let line = try! NSRegularExpression(pattern: #"(\/\w+)+\.\w+:"#)
}
// swiftlint:enable force_try
struct CustomDiagnostic {
let kind: Kind
let file: String
let line: String.SubSequence
let char: String.SubSequence
let code: String
let message: String.SubSequence
enum Kind: String {
case error, warning, note
var color: String {
switch self {
case .error: return "[41;1m" // bright red background
case .warning: return "[43;1m" // bright yellow background
case .note: return "[7m" // reversed
}
}
}
}
fileprivate static let highlighter = SyntaxHighlighter(format: TerminalOutputFormat())
func parse(_ output: String, _ terminal: InteractiveWriter) {
let lines = output.split(separator: "\n")
var lineIdx = 0
var diagnostics = [String.SubSequence: [CustomDiagnostic]]()
var currFile: String.SubSequence?
var fileMessages = [CustomDiagnostic]()
while lineIdx < lines.count {
let line = lines[lineIdx]
if let file = line.matches(regex: Regex.enterFile) {
if let currFile = currFile {
diagnostics[currFile] = fileMessages
}
currFile = file
fileMessages = []
} else if let currFile = currFile {
if let message = line.matches(regex: Regex.line) {
let components = message.split(separator: ":")
if components.count > 3 {
lineIdx += 1
let file = line.replacingOccurrences(of: message, with: "")
guard file.split(separator: "/").last?
.replacingOccurrences(of: ":", with: "") == String(currFile)
else { continue }
fileMessages.append(
.init(
kind: CustomDiagnostic
.Kind(rawValue: String(components[2]
.trimmingCharacters(in: .whitespaces))) ??
.note,
file: file,
line: components[0],
char: components[1],
code: String(lines[lineIdx]),
message: components[3]
)
)
}
}
} else {
terminal.write(String(line) + "\n", inColor: .cyan)
}
lineIdx += 1
}
if let currFile = currFile {
diagnostics[currFile] = fileMessages
}
outputDiagnostics(diagnostics, terminal)
}
func outputDiagnostics(
_ diagnostics: [String.SubSequence: [CustomDiagnostic]],
_ terminal: InteractiveWriter
) {
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
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
{
groupedMessages[groupedMessages.count - 1].append(message)
} else {
groupedMessages.append([message])
}
}
for messages in groupedMessages {
// Output the diagnostic message
for message in messages {
let kind = message.kind.rawValue.uppercased()
terminal
.write(
" \(" \(kind) ", color: message.kind.color, "[37;1m") \(message.message)\n"
) // 37;1: bright white
}
let maxLine = messages.map(\.line.count).max() ?? 0
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)
}
} else {
flush(messages: messages, message: message, maxLine: maxLine, terminal)
}
}
terminal.write("\n")
}
terminal.write("\n")
}
}
func flush(
messages: [CustomDiagnostic],
message: CustomDiagnostic,
maxLine: Int,
_ terminal: InteractiveWriter
) {
// Get all diagnostics for a particular line.
let allChars = messages.filter { $0.line == message.line }.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
)
// Aggregate the indicators (^ point to the error) onto a single line
var charIndicators = String(repeating: " ", count: Int(message.char)!) + "^"
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) + "^")
} else {
var arr = Array(charIndicators)
arr[idx] = "^"
charIndicators = String(arr)
}
}
}
terminal.write("\(charIndicators)\n", inColor: .red, bold: true)
}
}

View File

@ -53,4 +53,12 @@ public final class InteractiveWriter {
stream.flush()
}
}
public func saveCursor() {
term?.write("\u{001B}[s")
}
public func revertCursorAndClear() {
term?.write("\u{001B}[u\u{001B}[2J\u{001B}H")
}
}

View File

@ -41,18 +41,23 @@ public final class ProcessRunner {
private var subscription: AnyCancellable?
// swiftlint:disable:next function_body_length
public init(
_ arguments: [String],
clearOutputLines: Bool = true,
loadingMessage: String = "Running...",
_ terminal: InteractiveWriter
) {
let subject = PassthroughSubject<String, Error>()
var tmpOutput = ""
publisher = subject
.handleEvents(
receiveOutput: {
if clearOutputLines {
// Aggregate this for formatting later
terminal.clearLine()
terminal.write(String($0.dropLast()))
terminal.write(loadingMessage, inColor: .yellow)
tmpOutput += $0
} else {
terminal.write($0)
}
@ -69,12 +74,17 @@ public final class ProcessRunner {
case let .failure(error):
let errorString = String(describing: error)
if errorString.isEmpty {
terminal.clearLine()
terminal.write(
"\nProcess failed, check the build process output above.\n",
"Compilation failed.\n\n",
inColor: .red
)
DiagnosticsParser().parse(tmpOutput, terminal)
} else {
terminal.write("\nProcess failed and produced following output: \n", inColor: .red)
terminal.write(
"\nProcess failed and produced following output: \n",
inColor: .red
)
print(error)
}
}
@ -113,7 +123,8 @@ public final class ProcessRunner {
subject.send(completion: .failure(error))
default:
let errorDescription = String(data: Data(stderrBuffer), encoding: .utf8) ?? ""
return subject.send(completion: .failure(ProcessRunnerError(description: errorDescription)))
return subject
.send(completion: .failure(ProcessRunnerError(description: errorDescription)))
}
}
}

View File

@ -16,7 +16,7 @@ import TSCBasic
private extension String {
static var home = "\u{001B}[H"
static var clearScreen = "\u{001B}[2J"
static var clearScreen = "\u{001B}[2J\u{001B}[H\u{001B}[3J"
static var clear = "\u{001B}[J"
}

View File

@ -223,10 +223,10 @@ public final class Toolchain {
let builderArguments = try [
swiftPath.pathString, "build", "-c", isRelease ? "release" : "debug", "--product", product,
"--enable-test-discovery", "--destination", destination ?? inferDestinationPath().pathString,
"-Xswiftc", "-color-diagnostics",
]
try ProcessRunner(builderArguments, terminal).waitUntilFinished()
try ProcessRunner(builderArguments, loadingMessage: "Compiling...", terminal)
.waitUntilFinished()
guard localFileSystem.exists(mainWasmPath) else {
terminal.write(

View File

@ -61,6 +61,11 @@ struct Dev: ParsableCommand {
let toolchain = try Toolchain(localFileSystem, terminal)
if !verbose {
terminal.clearWindow()
terminal.saveCursor()
}
let (arguments, mainWasmPath) = try toolchain.buildCurrentProject(
product: product,
destination: destination,
@ -70,8 +75,7 @@ struct Dev: ParsableCommand {
let paths = try toolchain.inferSourcesPaths()
if !verbose {
terminal.clearWindow()
terminal.homeAndClear()
terminal.revertCursorAndClear()
}
terminal.write("\nWatching these directories for changes:\n", inColor: .green)
paths.forEach { terminal.logLookup("", $0) }

View File

@ -79,7 +79,7 @@ final class Server {
watcher.publisher
.flatMap(maxPublishers: .max(1)) { changes -> AnyPublisher<String, Never> in
if !verbose {
terminal.homeAndClear()
terminal.clearWindow()
}
terminal.write("\nThese paths have changed, rebuilding...\n", inColor: .yellow)
for change in changes.map(\.pathString) {