diff --git a/Sources/SwiftDiagnostics/CMakeLists.txt b/Sources/SwiftDiagnostics/CMakeLists.txt index 98070223502..6273688c218 100644 --- a/Sources/SwiftDiagnostics/CMakeLists.txt +++ b/Sources/SwiftDiagnostics/CMakeLists.txt @@ -7,6 +7,10 @@ # See http://swift.org/CONTRIBUTORS.txt for Swift project authors add_swift_syntax_library(SwiftDiagnostics + DiagnosticDecorators/ANSIDiagnosticDecorator.swift + DiagnosticDecorators/BasicDiagnosticDecorator.swift + DiagnosticDecorators/DiagnosticDecorator.swift + Convenience.swift Diagnostic.swift DiagnosticsFormatter.swift diff --git a/Sources/SwiftDiagnostics/DiagnosticDecorators/ANSIDiagnosticDecorator.swift b/Sources/SwiftDiagnostics/DiagnosticDecorators/ANSIDiagnosticDecorator.swift new file mode 100644 index 00000000000..5d50df5d96e --- /dev/null +++ b/Sources/SwiftDiagnostics/DiagnosticDecorators/ANSIDiagnosticDecorator.swift @@ -0,0 +1,211 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 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 +// +//===----------------------------------------------------------------------===// + +extension DiagnosticDecorator where Self == ANSIDiagnosticDecorator { + /// - SeeAlso: ``ANSIDiagnosticDecorator`` + static var ANSI: Self { + Self() + } +} + +/// An implementation of the `DiagnosticDecorator` protocol that enhances various diagnostic elements—including messages, +/// buffer outlines, and code highlights—by applying severity-based prefixes and ANSI color codes. +/// +/// This decorator uses ANSI codes—control characters specialized for text formatting in terminals—to provide visual cues. +@_spi(Testing) public struct ANSIDiagnosticDecorator: DiagnosticDecorator { + + @_spi(Testing) public init() {} + + /// Decorates a diagnostic message by appending a severity-based prefix and applying ANSI color codes. + /// + /// - Parameters: + /// - message: The diagnostic message that needs to be decorated. + /// - severity: The severity level associated with the diagnostic message. + /// + /// - Returns: A string that combines the severity-specific prefix and the original diagnostic message, with ANSI colorization. + /// + /// ## Example + /// + /// ```swift + /// let decorator = ANSIDiagnosticDecorator() + /// let decoratedMessage = decorator.decorateMessage("File not found", basedOnSeverity: .error) + /// // Output would be: "error: File not found" + /// ``` + /// In this example, the "error: " prefix is colorized, likely appearing in red, while the message retains its default text color. + /// + /// For a similar colorized output in the console, you can use `printf` in Bash: + /// ```bash + /// printf "\e[1;31merror: \e[1;39mFile not found\e[0;0m\n" + /// ``` + @_spi(Testing) public func decorateMessage(_ message: String, basedOnSeverity severity: DiagnosticSeverity) -> String { + let severityText: String + let severityAnnotation: ANSIAnnotation + + switch severity { + case .error: + severityText = "error" + severityAnnotation = .errorText + + case .warning: + severityText = "warning" + severityAnnotation = .warningText + + case .note: + severityText = "note" + severityAnnotation = .noteText + + case .remark: + severityText = "remark" + severityAnnotation = .remarkText + } + + let prefix = colorizeIfNotEmpty("\(severityText): ", usingAnnotation: severityAnnotation, resetAfterApplication: false) + + return prefix + colorizeIfNotEmpty(message, usingAnnotation: .diagnosticText) + } + + /// Decorates a source code buffer outline using ANSI cyan color codes. + /// + /// - Parameter bufferOutline: The string representation of the source code buffer outline. + /// + /// - Returns: A string featuring ANSI cyan color codes applied to the source code buffer outline. + @_spi(Testing) public func decorateBufferOutline(_ bufferOutline: String) -> String { + colorizeIfNotEmpty(bufferOutline, usingAnnotation: .bufferOutline) + } + + /// Emphasizes a specific text segment within a source code snippet using ANSI color codes. + /// + /// - Parameter highlight: The text segment within the source code snippet that should be emphasized. + /// + /// - Returns: A tuple containing: + /// - `highlightedSourceCode`: The underlined version of the original source code snippet. + /// - `additionalHighlightedLine`: Always nil. + /// + /// ## Example + /// + /// ```swift + /// let decorator = ANSIDiagnosticDecorator() + /// let decoratedHighlight = decorator.decorateHighlight("let x = 10") + /// // Output would be: ["\u{1B}[4;39mlet x = 10\u{1B}[0;0m"] + /// ``` + /// + /// To reproduce a similar colorized output manually in the console, you can use `printf` in Bash: + /// ```bash + /// printf "\e[4;39mlet x = 10\e[0;0m\n" + /// ``` + @_spi(Testing) public func decorateHighlight(_ highlight: String) -> (highlightedSourceCode: String, additionalHighlightedLine: String?) { + (highlightedSourceCode: colorizeIfNotEmpty(highlight, usingAnnotation: .sourceHighlight), additionalHighlightedLine: nil) + } + + /// Applies ANSI annotation to a given text segment, if the text is not empty. + /// + /// - Parameters: + /// - text: The text segment to which the annotation should be applied. + /// - annotation: The ANSI annotation to apply. + /// - resetAfter: A flag indicating whether to reset ANSI settings after applying them. Defaults to true. + /// + /// - Returns: A potentially colorized version of the input text. + private func colorizeIfNotEmpty( + _ text: String, + usingAnnotation annotation: ANSIAnnotation, + resetAfterApplication resetAfter: Bool = true + ) -> String { + if text.isEmpty { + return text + } else { + return annotation.applied(to: text, resetAfter: resetAfter) + } + } +} + +/// Defines text attributes to be applied to console output. +private struct ANSIAnnotation { + /// Represents ANSI color codes. + enum Color: UInt8 { + case normal = 0 + case black = 30 + case red = 31 + case green = 32 + case yellow = 33 + case blue = 34 + case magenta = 35 + case cyan = 36 + case white = 37 + case `default` = 39 + } + + /// Represents ANSI text traits. + enum Trait: UInt8 { + case normal = 0 + case bold = 1 + case underline = 4 + } + + /// The ANSI color to be used. + let color: Color + + /// The ANSI text trait to be used. + let trait: Trait + + /// Returns ANSI code as a string, including both trait and color. + var code: String { + "\u{001B}[\(trait.rawValue);\(color.rawValue)m" + } + + /// Applies the ANSI code to a message string. Optionally resets the code after the message. + func applied(to message: String, resetAfter: Bool = true) -> String { + guard resetAfter else { + return "\(code)\(message)" + } + return "\(code)\(message)\(ANSIAnnotation.normal.code)" + } + + /// The default 'normal' ANSIAnnotation used to reset styles. + static var normal: Self { + Self(color: .normal, trait: .normal) + } + + /// Annotation used for the outline and line numbers of a buffer. + static var bufferOutline: Self { + Self(color: .cyan, trait: .normal) + } + + /// Annotation used for highlighting source text. + static var sourceHighlight: Self { + Self(color: .default, trait: .underline) + } + + /// Annotation used for making text bold, commonly used in diagnostic messages. + static var diagnosticText: Self { + Self(color: .default, trait: .bold) + } + + /// Annotation used for error text. + static var errorText: Self { + Self(color: .red, trait: .bold) + } + + /// Annotation used for warning text. + static var warningText: Self { + Self(color: .yellow, trait: .bold) + } + + /// Annotation used for note text. + static var noteText: Self { + Self(color: .default, trait: .bold) + } + + /// Annotation used for remarks or less critical text. + static var remarkText: Self { + Self(color: .blue, trait: .bold) + } +} diff --git a/Sources/SwiftDiagnostics/DiagnosticDecorators/BasicDiagnosticDecorator.swift b/Sources/SwiftDiagnostics/DiagnosticDecorators/BasicDiagnosticDecorator.swift new file mode 100644 index 00000000000..44dfafc90e1 --- /dev/null +++ b/Sources/SwiftDiagnostics/DiagnosticDecorators/BasicDiagnosticDecorator.swift @@ -0,0 +1,71 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 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 +// +//===----------------------------------------------------------------------===// + +extension DiagnosticDecorator where Self == BasicDiagnosticDecorator { + /// - Seealso: ``BasicDiagnosticDecorator`` + static var basic: Self { + Self() + } +} + +/// An implementation of the `DiagnosticDecorator` protocol that enhances diagnostic elements—such as messages, +/// buffer outlines, and code highlights—by appending severity-based prefixes. +/// +/// Unlike `ANSIDiagnosticDecorator`, this decorator does not use ANSI color codes and solely relies on textual cues. +@_spi(Testing) public struct BasicDiagnosticDecorator: DiagnosticDecorator { + + @_spi(Testing) public init() {} + + /// Decorates a diagnostic message by appending a severity-based prefix. + /// + /// - Parameters: + /// - message: The diagnostic message that needs to be decorated. + /// - severity: The severity level associated with the diagnostic message. + /// + /// - Returns: A string that combines the severity-specific prefix and the original diagnostic message. + @_spi(Testing) public func decorateMessage(_ message: String, basedOnSeverity severity: DiagnosticSeverity) -> String { + let severityText: String + + switch severity { + case .error: + severityText = "error" + case .warning: + severityText = "warning" + case .note: + severityText = "note" + case .remark: + severityText = "remark" + } + + return severityText + ": " + message + } + + /// Passes through the source code buffer outline without modification. + /// + /// - Parameter bufferOutline: The string representation of the source code buffer outline. + /// + /// - Returns: The original source code buffer outline. + @_spi(Testing) public func decorateBufferOutline(_ bufferOutline: String) -> String { + return bufferOutline + } + + /// Passes through the text segment within a source code snippet without modification. + /// + /// - Parameter highlight: The text segment within the source code snippet that should be emphasized. + /// + /// - Returns: A tuple containing: + /// - `highlightedSourceCode`: The original text segment. + /// - `additionalHighlightedLine`: Always nil. + @_spi(Testing) public func decorateHighlight(_ highlight: String) -> (highlightedSourceCode: String, additionalHighlightedLine: String?) { + return (highlightedSourceCode: highlight, additionalHighlightedLine: nil) + } +} diff --git a/Sources/SwiftDiagnostics/DiagnosticDecorators/DiagnosticDecorator.swift b/Sources/SwiftDiagnostics/DiagnosticDecorators/DiagnosticDecorator.swift new file mode 100644 index 00000000000..958e8383663 --- /dev/null +++ b/Sources/SwiftDiagnostics/DiagnosticDecorators/DiagnosticDecorator.swift @@ -0,0 +1,74 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 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 +// +//===----------------------------------------------------------------------===// + +/// Protocol that defines a standard interface for decorating diagnostic output in source code. +/// +/// This protocol is intended to be used by entities such as ``DiagnosticsFormatter`` and ``GroupedDiagnostics`` +/// to apply custom decorations to diagnostic messages, buffer outlines, and code highlights. +/// +/// ## Conforming to `DiagnosticDecorator`: +/// +/// To conform to the `DiagnosticDecorator` protocol, you must implement three required methods: +/// +/// 1. `decorateMessage(_:basedOnSeverity:)`: For decorating diagnostic messages. +/// 2. `decorateBufferOutline(_:)`: For decorating the outlines of source code buffers. +/// 3. `decorateHighlight(_:)`: For decorating individual highlights within a source code snippet. +/// +/// ## Customization: +/// +/// The protocol is designed to be easily customizable. Developers can create their own entities that conform +/// to `DiagnosticDecorator` to implement custom decorating logic. This allows for different visual representations, +/// such as using ANSI colors, underscores, emoji-based or other markers, for diagnostics in source code. +protocol DiagnosticDecorator { + /// Decorates a diagnostic message based on its severity level. + /// + /// Implementations are expected to prepend a severity-specific prefix (e.g., "error: ", "warning: ") to the diagnostic message. + /// + /// - Parameters: + /// - message: The diagnostic message that needs to be decorated. + /// - severity: The severity level associated with the diagnostic message. + /// + /// - Returns: A decorated version of the diagnostic message, enhanced by visual cues like color, text styles, or other markers, + /// as well as a severity-specific prefix, based on its severity level. + func decorateMessage(_ message: String, basedOnSeverity severity: DiagnosticSeverity) -> String + + /// Decorates the outline of a source code buffer to visually enhance its structure. + /// + /// - Parameter bufferOutline: The string representation of the source code buffer outline. + /// + /// - Returns: A decorated version of the buffer outline, improved with visual cues like color, text styles, or other markers. + func decorateBufferOutline(_ bufferOutline: String) -> String + + /// Decorates a highlight within a source code snippet to emphasize it. + /// + /// - Parameter highlight: The text segment within the source code snippet that should be emphasized. + /// + /// - Returns: A tuple containing: + /// - `highlightedSourceCode`: A string that represents the decorated version of the original source code snippet. + /// - `additionalHighlightedLine`: An optional string containing additional lines of highlighting, if applicable. + /// + /// - Note: The method returns a tuple to offer more flexibility in decorating highlights. + /// This allows for a variety of techniques to be used, such as ANSI codes for color + /// and additional lines for contextual emphasis, which will be combined during the rendering process. + func decorateHighlight(_ highlight: String) -> (highlightedSourceCode: String, additionalHighlightedLine: String?) +} + +extension DiagnosticDecorator { + /// Decorates a ``DiagnosticMessage`` instance by delegating to the `decorateMessage(_:basedOnSeverity:)` method. + /// + /// - Parameter diagnosticMessage: The ``DiagnosticMessage`` instance that encapsulates both the message and its severity level. + /// + /// - Returns: A decorated version of the diagnostic message, determined by its severity level. + func decorateDiagnosticMessage(_ diagnosticMessage: DiagnosticMessage) -> String { + decorateMessage(diagnosticMessage.message, basedOnSeverity: diagnosticMessage.severity) + } +} diff --git a/Sources/SwiftDiagnostics/DiagnosticsFormatter.swift b/Sources/SwiftDiagnostics/DiagnosticsFormatter.swift index f55c9b42411..0cf9bcd9efe 100644 --- a/Sources/SwiftDiagnostics/DiagnosticsFormatter.swift +++ b/Sources/SwiftDiagnostics/DiagnosticsFormatter.swift @@ -73,12 +73,20 @@ public struct DiagnosticsFormatter { /// Number of lines which should be printed before and after the diagnostic message public let contextSize: Int - /// Whether to colorize formatted diagnostics. - public let colorize: Bool + /// An instance that conforms to the ``DiagnosticDecorator`` protocol, responsible for formatting diagnostic messages. + /// + /// This property allows for the flexible customization of diagnostic messages, buffer outlines, and code highlighting. + /// Different implementations can be swapped in to tailor the output according to user preferences or specific environmental conditions. + let diagnosticDecorator: DiagnosticDecorator + + @available(*, deprecated, message: "Store the `colorize` property passed to the initializer instead") + public var colorize: Bool { + return diagnosticDecorator is ANSIDiagnosticDecorator + } public init(contextSize: Int = 2, colorize: Bool = false) { self.contextSize = contextSize - self.colorize = colorize + self.diagnosticDecorator = colorize ? .ANSI : .basic } public static func annotatedSource( @@ -98,7 +106,7 @@ public struct DiagnosticsFormatter { tree: some SyntaxProtocol, sourceLocationConverter slc: SourceLocationConverter ) -> String { - guard colorize, !annotatedLine.diagnostics.isEmpty else { + if annotatedLine.diagnostics.isEmpty { return annotatedLine.sourceString } @@ -158,7 +166,6 @@ public struct DiagnosticsFormatter { // Form the annotated string by copying in text from the original source, // highlighting the column ranges. var resultSourceString: String = "" - let annotation = ANSIAnnotation.sourceHighlight let sourceString = annotatedLine.sourceString var sourceIndex = sourceString.startIndex for highlightRange in highlightIndexRanges { @@ -167,7 +174,7 @@ public struct DiagnosticsFormatter { // Highlighted source text let highlightString = String(sourceString[highlightRange]) - resultSourceString += annotation.applied(to: highlightString) + resultSourceString += diagnosticDecorator.decorateHighlight(highlightString).highlightedSourceCode sourceIndex = highlightRange.upperBound } @@ -239,11 +246,11 @@ public struct DiagnosticsFormatter { // line numbers should be right aligned let lineNumberString = String(lineNumber) let leadingSpaces = String(repeating: " ", count: maxNumberOfDigits - lineNumberString.count) - let linePrefix = "\(leadingSpaces)\(colorizeBufferOutline("\(lineNumberString) │")) " + let linePrefix = "\(leadingSpaces)\(diagnosticDecorator.decorateBufferOutline("\(lineNumberString) │")) " // If necessary, print a line that indicates that there was lines skipped in the source code if hasLineBeenSkipped && !annotatedSource.isEmpty { - let lineMissingInfoLine = indentString + String(repeating: " ", count: maxNumberOfDigits) + " \(colorizeBufferOutline("┆"))" + let lineMissingInfoLine = indentString + String(repeating: " ", count: maxNumberOfDigits) + " \(diagnosticDecorator.decorateBufferOutline("┆"))" annotatedSource.append("\(lineMissingInfoLine)\n") } hasLineBeenSkipped = false @@ -276,7 +283,7 @@ public struct DiagnosticsFormatter { for (column, diags) in diagsPerColumn { // compute the string that is shown before each message - var preMessage = indentString + String(repeating: " ", count: maxNumberOfDigits) + " " + colorizeBufferOutline("│") + var preMessage = indentString + String(repeating: " ", count: maxNumberOfDigits) + " " + diagnosticDecorator.decorateBufferOutline("│") for c in 0.. String { - colorizeIfRequested(severity: message.severity, message: message.message) - } - - /// Annotates a diagnostic message with the given severity and text with an appropriate ANSI color code. - func colorizeIfRequested(severity: DiagnosticSeverity, message: String) -> String { - let severityText: String - let severityAnnotation: ANSIAnnotation - - switch severity { - case .error: - severityText = "error" - severityAnnotation = .errorText - - case .warning: - severityText = "warning" - severityAnnotation = .warningText - - case .note: - severityText = "note" - severityAnnotation = .noteText - - case .remark: - severityText = "remark" - severityAnnotation = .remarkText - } - - let prefix = colorizeIfRequested("\(severityText): ", annotation: severityAnnotation, resetAfter: false) - - return prefix + colorizeIfRequested(message, annotation: .diagnosticText); - } - - /// Apply the given color and trait to the specified text, when we are - /// supposed to color the output. - private func colorizeIfRequested( - _ text: String, - annotation: ANSIAnnotation, - resetAfter: Bool = true - ) -> String { - guard colorize, !text.isEmpty else { - return text - } - - return annotation.applied(to: text, resetAfter: resetAfter) - } - - /// Colorize for the buffer outline and line numbers. - func colorizeBufferOutline(_ text: String) -> String { - colorizeIfRequested(text, annotation: .bufferOutline) - } -} - -struct ANSIAnnotation { - enum Color: UInt8 { - case normal = 0 - case black = 30 - case red = 31 - case green = 32 - case yellow = 33 - case blue = 34 - case magenta = 35 - case cyan = 36 - case white = 37 - case `default` = 39 - } - - enum Trait: UInt8 { - case normal = 0 - case bold = 1 - case underline = 4 - } - - var color: Color - var trait: Trait - - /// The textual representation of the annotation. - var code: String { - "\u{001B}[\(trait.rawValue);\(color.rawValue)m" - } - - init(color: Color, trait: Trait = .normal) { - self.color = color - self.trait = trait - } - - func withTrait(_ trait: Trait) -> Self { - return ANSIAnnotation(color: self.color, trait: trait) - } - - func applied(to message: String, resetAfter: Bool = true) -> String { - guard resetAfter else { - return "\(code)\(message)" - } - - // Resetting after the message ensures that we don't color unintended lines in the output - return "\(code)\(message)\(ANSIAnnotation.normal.code)" - } - - /// The "normal" or "reset" ANSI code used to unset any previously added annotation. - static var normal: ANSIAnnotation { - self.init(color: .normal, trait: .normal) - } - - /// Annotation used for the outline and line numbers of a buffer. - static var bufferOutline: ANSIAnnotation { - ANSIAnnotation(color: .cyan, trait: .normal) - } - - /// Annotation used for highlighting source text. - static var sourceHighlight: ANSIAnnotation { - ANSIAnnotation(color: .default, trait: .underline) - } - - static var diagnosticText: ANSIAnnotation { - ANSIAnnotation(color: .default, trait: .bold) - } - - static var errorText: ANSIAnnotation { - ANSIAnnotation(color: .red, trait: .bold) - } - - static var warningText: ANSIAnnotation { - ANSIAnnotation(color: .yellow, trait: .bold) - } - - static var noteText: ANSIAnnotation { - ANSIAnnotation(color: .default, trait: .bold) - } - - static var remarkText: ANSIAnnotation { - ANSIAnnotation(color: .blue, trait: .bold) - } } diff --git a/Sources/SwiftDiagnostics/GroupedDiagnostics.swift b/Sources/SwiftDiagnostics/GroupedDiagnostics.swift index f0581d2e967..7efd1436bb6 100644 --- a/Sources/SwiftDiagnostics/GroupedDiagnostics.swift +++ b/Sources/SwiftDiagnostics/GroupedDiagnostics.swift @@ -171,8 +171,7 @@ extension GroupedDiagnostics { fileName: sourceFile.displayName, tree: sourceFile.tree ) - - let colorizeBufferOutline = formatter.colorizeBufferOutline + let diagnosticDecorator = formatter.diagnosticDecorator let childPadding = String(slc.sourceLines.count + 1).count + 1; @@ -200,7 +199,8 @@ extension GroupedDiagnostics { let location = primaryDiag.location(converter: primaryDiagSLC) // Display file/line/column and diagnostic text for the primary diagnostic. - prefixString = "\(location.file):\(location.line):\(location.column): \(formatter.colorizeIfRequested(primaryDiag.diagMessage))\n" + prefixString = + "\(location.file):\(location.line):\(location.column): \(diagnosticDecorator.decorateDiagnosticMessage(primaryDiag.diagMessage))\n" // If the primary diagnostic source file is not the same as the root source file, we're pointing into a generated buffer. // Provide a link back to the original source file where this generated buffer occurred, so it's easy to find if @@ -216,8 +216,8 @@ extension GroupedDiagnostics { if rootSourceID == sourceFileID { let bufferLoc = slc.location(for: rootPosition) - let coloredMessage = formatter.colorizeIfRequested(severity: .note, message: "expanded code originates here") - prefixString += "╰─ \(bufferLoc.file):\(bufferLoc.line):\(bufferLoc.column): \(coloredMessage)\n" + let decoratedMessage = diagnosticDecorator.decorateMessage("expanded code originates here", basedOnSeverity: .note) + prefixString += "╰─ \(bufferLoc.file):\(bufferLoc.line):\(bufferLoc.column): \(decoratedMessage)\n" } } } else { @@ -234,13 +234,14 @@ extension GroupedDiagnostics { let extraLengthNeeded = targetLineLength - padding.count - sourceFile.displayName.count - 6 let boxSuffix: String if extraLengthNeeded > 0 { - boxSuffix = colorizeBufferOutline(String(repeating: "─", count: extraLengthNeeded)) + boxSuffix = diagnosticDecorator.decorateBufferOutline(String(repeating: "─", count: extraLengthNeeded)) } else { boxSuffix = "" } - prefixString = colorizeBufferOutline(padding + "╭─── ") + sourceFile.displayName + " " + boxSuffix + "\n" - suffixString = colorizeBufferOutline(padding + "╰───" + String(repeating: "─", count: sourceFile.displayName.count + 2)) + boxSuffix + "\n" + prefixString = diagnosticDecorator.decorateBufferOutline(padding + "╭─── ") + sourceFile.displayName + " " + boxSuffix + "\n" + suffixString = + diagnosticDecorator.decorateBufferOutline(padding + "╰───" + String(repeating: "─", count: sourceFile.displayName.count + 2)) + boxSuffix + "\n" } // Render the buffer. @@ -248,7 +249,7 @@ extension GroupedDiagnostics { + formatter.annotatedSource( tree: sourceFile.tree, diags: sourceFile.diagnostics, - indentString: colorizeBufferOutline(indentString), + indentString: diagnosticDecorator.decorateBufferOutline(indentString), suffixTexts: childSources, sourceLocationConverter: slc ) + suffixString diff --git a/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/ANSIDiagnosticDecoratorTests.swift b/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/ANSIDiagnosticDecoratorTests.swift new file mode 100644 index 00000000000..0d39d7ebe61 --- /dev/null +++ b/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/ANSIDiagnosticDecoratorTests.swift @@ -0,0 +1,79 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 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 +// +//===----------------------------------------------------------------------===// + +@_spi(Testing) import SwiftDiagnostics +import XCTest +import _SwiftSyntaxTestSupport + +final class ANSIDiagnosticDecoratorTests: XCTestCase { + + private let decorator = ANSIDiagnosticDecorator() + + // MARK: - Decorate Message Tests + + func testDecorateMessage() { + let message = "File not found" + + let decoratedMessageForError = decorator.decorateMessage(message, basedOnSeverity: .error) + assertStringsEqualWithDiff(decoratedMessageForError, "\u{1B}[1;31merror: \u{1B}[1;39mFile not found\u{1B}[0;0m") + + let decoratedMessageForWarning = decorator.decorateMessage(message, basedOnSeverity: .warning) + assertStringsEqualWithDiff(decoratedMessageForWarning, "\u{1B}[1;33mwarning: \u{1B}[1;39mFile not found\u{1B}[0;0m") + + let decoratedMessageForNote = decorator.decorateMessage(message, basedOnSeverity: .note) + assertStringsEqualWithDiff(decoratedMessageForNote, "\u{1B}[1;39mnote: \u{1B}[1;39mFile not found\u{1B}[0;0m") + + let decoratedMessageForRemark = decorator.decorateMessage(message, basedOnSeverity: .remark) + assertStringsEqualWithDiff(decoratedMessageForRemark, "\u{1B}[1;34mremark: \u{1B}[1;39mFile not found\u{1B}[0;0m") + } + + func testDecorateMessageWithEmptyMessage() { + let decoratedMessageForError = decorator.decorateMessage("", basedOnSeverity: .error) + + assertStringsEqualWithDiff(decoratedMessageForError, "\u{1B}[1;31merror: ") + } + + // MARK: - Decorate Buffer Outline Tests + + func testDecorateBufferOutline() { + let bufferOutline = " │" + + let decoratedOutline = decorator.decorateBufferOutline(bufferOutline) + + assertStringsEqualWithDiff(decoratedOutline, "\u{1B}[0;36m │\u{1B}[0;0m") + } + + func testDecorateBufferOutlineWithEmptyString() { + + let decoratedOutline = decorator.decorateBufferOutline("") + + assertStringsEqualWithDiff(decoratedOutline, "") + } + + // MARK: - Decorate Highlight Tests + + func testDecorateHighlight() { + let highlightedText = "let x = 10" + + let decoratedHighlight = decorator.decorateHighlight(highlightedText) + + assertStringsEqualWithDiff(decoratedHighlight.highlightedSourceCode, "\u{1B}[4;39mlet x = 10\u{1B}[0;0m") + XCTAssertNil(decoratedHighlight.additionalHighlightedLine) + } + + func testDecorateHighlightWithEmptyString() { + let decoratedHighlight = decorator.decorateHighlight("") + + assertStringsEqualWithDiff(decoratedHighlight.highlightedSourceCode, "") + XCTAssertNil(decoratedHighlight.additionalHighlightedLine) + } +} diff --git a/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/BasicDiagnosticDecoratorTests.swift b/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/BasicDiagnosticDecoratorTests.swift new file mode 100644 index 00000000000..63d08b4ef3f --- /dev/null +++ b/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/BasicDiagnosticDecoratorTests.swift @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 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 +// +//===----------------------------------------------------------------------===// + +@_spi(Testing) import SwiftDiagnostics +import XCTest +import _SwiftSyntaxTestSupport + +final class BasicDiagnosticDecoratorTests: XCTestCase { + + private let decorator = BasicDiagnosticDecorator() + + // MARK: - Decorate Message Tests + + func testDecorateMessage() { + let message = "File not found" + + let decoratedMessageForError = decorator.decorateMessage(message, basedOnSeverity: .error) + assertStringsEqualWithDiff(decoratedMessageForError, "error: File not found") + + let decoratedMessageForWarning = decorator.decorateMessage(message, basedOnSeverity: .warning) + assertStringsEqualWithDiff(decoratedMessageForWarning, "warning: File not found") + + let decoratedMessageForNote = decorator.decorateMessage(message, basedOnSeverity: .note) + assertStringsEqualWithDiff(decoratedMessageForNote, "note: File not found") + + let decoratedMessageForRemark = decorator.decorateMessage(message, basedOnSeverity: .remark) + assertStringsEqualWithDiff(decoratedMessageForRemark, "remark: File not found") + } + + // MARK: - Decorate Buffer Outline Tests + + func testDecorateBufferOutline() { + let bufferOutline = "┆" + + let decoratedOutline = decorator.decorateBufferOutline(bufferOutline) + + assertStringsEqualWithDiff(decoratedOutline, bufferOutline) + } + + // MARK: - Decorate Highlight Tests + + func testDecorateHighlight() { + let highlightedText = "let x = 10" + + let decoratedHighlight = decorator.decorateHighlight(highlightedText) + + assertStringsEqualWithDiff(decoratedHighlight.highlightedSourceCode, highlightedText) + XCTAssertNil(decoratedHighlight.additionalHighlightedLine) + } +} diff --git a/Tests/SwiftDiagnosticsTest/DiagnosticTestingUtils.swift b/Tests/SwiftDiagnosticsTest/DiagnosticTestingUtils.swift new file mode 100644 index 00000000000..9987643d494 --- /dev/null +++ b/Tests/SwiftDiagnosticsTest/DiagnosticTestingUtils.swift @@ -0,0 +1,222 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 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 SwiftDiagnostics +import SwiftParser +import SwiftParserDiagnostics +import SwiftSyntax +import XCTest +import _SwiftSyntaxTestSupport + +/// A typealias representing a location marker. +/// +/// This string serves to pinpoint the exact location of a particular token in the SwiftSyntax tree. +/// Once the token location is identified, it can be leveraged for various test-specific operations such as inserting diagnostics, notes, or fix-its, +/// or for closer examination of the syntax tree. +/// +/// Markers are instrumental in writing unit tests that require precise location data. They are commonly represented using emojis like 1️⃣, 2️⃣, 3️⃣, etc., to improve readability. +/// +/// ### Example +/// +/// In the following test code snippet, the emojis 1️⃣ and 2️⃣ are used as location markers: +/// +/// ```swift +/// func foo() -> Int { +/// if 1️⃣1 != 0 2️⃣{ +/// return 0 +/// } +/// return 1 +/// } +/// ``` +typealias LocationMarker = String + +/// Represents a descriptor for constructing a diagnostic in testing. +struct DiagnosticDescriptor { + /// Represents errors that can occur while creating a `Diagnostic` instance. + private struct DiagnosticCreationError: Error, LocalizedError { + /// A human-readable message describing what went wrong. + let message: String + + /// A localized message describing what went wrong. Required by `LocalizedError`. + var errorDescription: String? { message } + } + + /// The marker pointing to location in source code. + let locationMarker: LocationMarker + + /// The ID associated with the message, used for categorizing or referencing it. + let id: MessageID + + /// The textual content of the message to be displayed. + let message: String + + /// The severity level of the diagnostic message. + let severity: DiagnosticSeverity + + /// The syntax elements to be highlighted for this diagnostic message. + let highlight: [Syntax] // TODO: How to create an abstract model for this? + + /// Descriptors for any accompanying notes for this diagnostic message. + let noteDescriptors: [NoteDescriptor] + + /// Descriptors for any Fix-Its that can be applied for this diagnostic message. + let fixIts: [FixIt] // TODO: How to create an abstract model for this? + + /// Initializes a new `DiagnosticDescriptor`. + /// + /// - Parameters: + /// - locationMarker: The marker pointing to location in source code. + /// - id: The message ID of the diagnostic. + /// - message: The textual message to display for the diagnostic. + /// - severity: The severity level of the diagnostic. Default is `.error`. + /// - highlight: The syntax elements to be highlighted. Default is an empty array. + /// - noteDescriptors: An array of note descriptors for additional context. Default is an empty array. + /// - fixIts: An array of Fix-It descriptors for quick fixes. Default is an empty array. + init( + locationMarker: LocationMarker, + id: MessageID = MessageID(domain: "test", id: "conjured"), + message: String, + severity: DiagnosticSeverity = .error, + highlight: [Syntax] = [], + noteDescriptors: [NoteDescriptor] = [], + fixIts: [FixIt] = [] + ) { + self.locationMarker = locationMarker + self.id = id + self.message = message + self.severity = severity + self.highlight = highlight + self.noteDescriptors = noteDescriptors + self.fixIts = fixIts + } + + /// Creates a ``Diagnostic`` instance from a given ``DiagnosticDescriptor``, syntax tree, and location markers. + /// + /// - Parameters: + /// - tree: The syntax tree where the diagnostic is rooted. + /// - markers: A dictionary mapping location markers to their respective offsets in the source code. + /// + /// - Throws: + /// - Error if the location marker is not found in the source code. + /// - Error if a node corresponding to a given marker is not found in the syntax tree. + /// + /// - Returns: A ``Diagnostic`` instance populated with details from the ``DiagnosticDescriptor``. + func createDiagnostic( + inSyntaxTree tree: some SyntaxProtocol, + usingLocationMarkers markers: [LocationMarker: Int] + ) throws -> Diagnostic { + func node(at marker: LocationMarker) throws -> Syntax { + guard let markedOffset = markers[marker] else { + throw DiagnosticCreationError(message: "Marker \(marker) not found in the marked source") + } + let markedPosition = AbsolutePosition(utf8Offset: markedOffset) + guard let token = tree.token(at: markedPosition) else { + throw DiagnosticCreationError(message: "Node not found at marker \(marker)") + } + return Syntax(token) + } + + let diagnosticNode = try node(at: self.locationMarker) + + let notes = try self.noteDescriptors.map { noteDescriptor in + Note( + node: try node(at: noteDescriptor.locationMarker), + message: SimpleNoteMessage(message: noteDescriptor.message, noteID: noteDescriptor.id) + ) + } + + return Diagnostic( + node: diagnosticNode, + message: SimpleDiagnosticMessage( + message: self.message, + diagnosticID: self.id, + severity: self.severity + ), + highlights: self.highlight, + notes: notes, + fixIts: self.fixIts + ) + } +} + +/// Represents a descriptor for constructing a note message in testing. +struct NoteDescriptor { + /// The marker pointing to location in source code. + let locationMarker: LocationMarker + + /// The ID associated with the note message. + let id: MessageID + + /// The textual content of the note to be displayed. + let message: String +} + +/// A simple implementation of the `NoteMessage` protocol for testing. +/// This struct holds the message text and a fix-it ID for a note. +struct SimpleNoteMessage: NoteMessage { + /// The textual content of the note to be displayed. + let message: String + + /// The unique identifier for this note message. + let noteID: MessageID +} + +/// A simple implementation of the `DiagnosticMessage` protocol for testing. +/// This struct holds the message text, diagnostic ID, and severity for a diagnostic. +struct SimpleDiagnosticMessage: DiagnosticMessage { + /// The textual content of the diagnostic message to be displayed. + let message: String + + /// The ID associated with the diagnostic message for categorization or referencing. + let diagnosticID: MessageID + + /// The severity level of the diagnostic message. + let severity: DiagnosticSeverity +} + +/// Asserts that the annotated source generated from diagnostics matches an expected annotated source. +/// +/// - Parameters: +/// - markedSource: The source code with location markers `LocationMarker` for diagnostics. +/// - withDiagnostics: An array of diagnostic descriptors to generate diagnostics. +/// - matches: The expected annotated source after applying the diagnostics. +/// - file: The file in which failure occurred. +/// - line: The line number on which failure occurred. +func assertAnnotated( + markedSource: String, + withDiagnostics diagnosticDescriptors: [DiagnosticDescriptor], + matches expectedAnnotatedSource: String, + file: StaticString = #file, + line: UInt = #line +) { + let (markers, source) = extractMarkers(markedSource) + let tree = Parser.parse(source: source) + + var diagnostics: [Diagnostic] = [] + + do { + diagnostics = try diagnosticDescriptors.map { + try $0.createDiagnostic(inSyntaxTree: tree, usingLocationMarkers: markers) + } + } catch { + XCTFail(error.localizedDescription, file: file, line: line) + } + + let annotatedSource = DiagnosticsFormatter.annotatedSource(tree: tree, diags: diagnostics) + + assertStringsEqualWithDiff( + annotatedSource, + expectedAnnotatedSource, + file: file, + line: line + ) +} diff --git a/Tests/SwiftDiagnosticsTest/GroupDiagnosticsFormatterTests.swift b/Tests/SwiftDiagnosticsTest/GroupDiagnosticsFormatterTests.swift index 2bb8055ce30..3c4e6fd18ae 100644 --- a/Tests/SwiftDiagnosticsTest/GroupDiagnosticsFormatterTests.swift +++ b/Tests/SwiftDiagnosticsTest/GroupDiagnosticsFormatterTests.swift @@ -17,16 +17,6 @@ import SwiftSyntax import XCTest import _SwiftSyntaxTestSupport -struct SimpleDiagnosticMessage: DiagnosticMessage { - let message: String - let diagnosticID: MessageID - let severity: DiagnosticSeverity -} - -extension SimpleDiagnosticMessage: FixItMessage { - var fixItID: MessageID { diagnosticID } -} - extension GroupedDiagnostics { /// Add a new test file to the group, starting with marked source and using /// the markers to add any suggested extra diagnostics at the marker @@ -35,34 +25,30 @@ extension GroupedDiagnostics { _ markedSource: String, displayName: String, parent: (SourceFileID, AbsolutePosition)? = nil, - extraDiagnostics: [String: (String, DiagnosticSeverity)] = [:] + diagnosticDescriptors: [DiagnosticDescriptor], + file: StaticString = #file, + line: UInt = #line ) -> (SourceFileID, [String: AbsolutePosition]) { - // Parse the source file and produce parser diagnostics. let (markers, source) = extractMarkers(markedSource) let tree = Parser.parse(source: source) - var diagnostics = ParseDiagnosticsGenerator.diagnostics(for: tree) - - // Add on any extra diagnostics provided, at their marker locations. - for (marker, (message, severity)) in extraDiagnostics { - let pos = AbsolutePosition(utf8Offset: markers[marker]!) - let node = tree.token(at: pos)!.parent! - - let diag = Diagnostic( - node: node, - message: SimpleDiagnosticMessage( - message: message, - diagnosticID: MessageID(domain: "test", id: "conjured"), - severity: severity - ) - ) - diagnostics.append(diag) + + let parserDiagnostics = ParseDiagnosticsGenerator.diagnostics(for: tree) + + var additionalDiagnostics: [Diagnostic] = [] + + do { + additionalDiagnostics = try diagnosticDescriptors.map { + try $0.createDiagnostic(inSyntaxTree: tree, usingLocationMarkers: markers) + } + } catch { + XCTFail(error.localizedDescription, file: file, line: line) } let id = addSourceFile( tree: tree, displayName: displayName, parent: parent, - diagnostics: diagnostics + diagnostics: parserDiagnostics + additionalDiagnostics ) let markersWithAbsPositions = markers.map { (marker, pos) in @@ -88,7 +74,13 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase { print("hello" """, displayName: "main.swift", - extraDiagnostics: ["1️⃣": ("in expansion of macro 'myAssert' here", .note)] + diagnosticDescriptors: [ + DiagnosticDescriptor( + locationMarker: "1️⃣", + message: "in expansion of macro 'myAssert' here", + severity: .note + ) + ] ) let inExpansionNotePos = mainSourceMarkers["1️⃣"]! @@ -103,8 +95,12 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase { """, displayName: "#myAssert", parent: (mainSourceID, inExpansionNotePos), - extraDiagnostics: [ - "1️⃣": ("no matching operator '==' for types 'Double' and 'Int'", .error) + diagnosticDescriptors: [ + DiagnosticDescriptor( + locationMarker: "1️⃣", + message: "no matching operator '==' for types 'Double' and 'Int'", + severity: .error + ) ] ) @@ -143,7 +139,9 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase { print("hello") """, displayName: "main.swift", - extraDiagnostics: ["1️⃣": ("in expansion of macro 'myAssert' here", .note)] + diagnosticDescriptors: [ + DiagnosticDescriptor(locationMarker: "1️⃣", message: "in expansion of macro 'myAssert' here", severity: .note) + ] ) let inExpansionNotePos = mainSourceMarkers["1️⃣"]! @@ -158,8 +156,8 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase { """, displayName: "#myAssert", parent: (mainSourceID, inExpansionNotePos), - extraDiagnostics: [ - "1️⃣": ("in expansion of macro 'invertedEqualityCheck' here", .note) + diagnosticDescriptors: [ + DiagnosticDescriptor(locationMarker: "1️⃣", message: "in expansion of macro 'invertedEqualityCheck' here", severity: .note) ] ) let inInnerExpansionNotePos = outerExpansionSourceMarkers["1️⃣"]! @@ -171,8 +169,8 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase { """, displayName: "#invertedEqualityCheck", parent: (outerExpansionSourceID, inInnerExpansionNotePos), - extraDiagnostics: [ - "1️⃣": ("no matching operator '==' for types 'Double' and 'Int'", .error) + diagnosticDescriptors: [ + DiagnosticDescriptor(locationMarker: "1️⃣", message: "no matching operator '==' for types 'Double' and 'Int'", severity: .error) ] ) diff --git a/Tests/SwiftDiagnosticsTest/DiagnosticsFormatterTests.swift b/Tests/SwiftDiagnosticsTest/ParserDiagnosticsFormatterIntegrationTests.swift similarity index 98% rename from Tests/SwiftDiagnosticsTest/DiagnosticsFormatterTests.swift rename to Tests/SwiftDiagnosticsTest/ParserDiagnosticsFormatterIntegrationTests.swift index 836b19def75..317de3b31c9 100644 --- a/Tests/SwiftDiagnosticsTest/DiagnosticsFormatterTests.swift +++ b/Tests/SwiftDiagnosticsTest/ParserDiagnosticsFormatterIntegrationTests.swift @@ -16,7 +16,7 @@ import SwiftParserDiagnostics import XCTest import _SwiftSyntaxTestSupport -final class DiagnosticsFormatterTests: XCTestCase { +final class ParserDiagnosticsFormatterIntegrationTests: XCTestCase { func annotate(source: String, colorize: Bool = false) -> String { let tree = Parser.parse(source: source)