From ede2ce8f0272ad3236e794b13f10ccdf4699089c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ba=CC=A8k?= Date: Mon, 25 Sep 2023 16:59:08 +0200 Subject: [PATCH] Refactor DiagnosticsFormatter and extend its functionality This commit refactors the existing code for `DiagnosticsFormatter` and introduces several new features, complete with documentation and unit tests. Key Enhancements: 1. Nested Diagnostic Support: Enhanced to include not only top-level diagnostics but also related notes, improving the debugging experience. 2. Custom Decorators: Incorporate the `DiagnosticDecorator` protocol, allowing for custom formatting and styling of diagnostic output. 3. Context Size Control: Added options to control the `ContextSize`, providing more flexibility in how much source code context is displayed around each diagnostic. Documentation: - Comprehensive documentation added, detailing the purpose, usage examples, and future developments for `DiagnosticsFormatter`. Testing: - Added robust unit tests to validate the new features and ensure reliability. This refactor and feature addition make `DiagnosticsFormatter` a more versatile and developer-friendly tool for debugging and understanding Swift code. --- .../DiagnosticsFormatter.swift | 974 +++++++++++++----- .../SwiftDiagnostics/GroupedDiagnostics.swift | 21 +- .../DiagnosticTestingUtils.swift | 51 +- .../DiagnosticsFormatterTests.swift | 280 +++++ .../GroupDiagnosticsFormatterTests.swift | 53 +- ...DiagnosticsFormatterIntegrationTests.swift | 96 +- 6 files changed, 1188 insertions(+), 287 deletions(-) create mode 100644 Tests/SwiftDiagnosticsTest/DiagnosticsFormatterTests.swift diff --git a/Sources/SwiftDiagnostics/DiagnosticsFormatter.swift b/Sources/SwiftDiagnostics/DiagnosticsFormatter.swift index 0cf9bcd9efe..f63632d163e 100644 --- a/Sources/SwiftDiagnostics/DiagnosticsFormatter.swift +++ b/Sources/SwiftDiagnostics/DiagnosticsFormatter.swift @@ -12,311 +12,809 @@ import SwiftSyntax -extension Sequence where Element == Range { - /// Given a set of ranges that are sorted in order of nondecreasing lower - /// bound, merge any overlapping ranges to produce a sequence of - /// nonoverlapping ranges. - fileprivate func mergingOverlappingRanges() -> [Range] { - var result: [Range] = [] - - var prior: Range? = nil - for range in self { - // If this is the first range we've seen, note it as the prior and - // continue. - guard let priorRange = prior else { - prior = range - continue - } - - // If the ranges overlap, expand the prior range. - precondition(priorRange.lowerBound <= range.lowerBound) - if priorRange.overlaps(range) { - let lower = priorRange.lowerBound - let upper = Swift.max(priorRange.upperBound, range.upperBound) - prior = lower.. 0 } +/// +/// init(counter: Int = 0) { +/// self.counter = counter +/// } +/// +/// // Description of the `loopOver` function +/// func loopOver() { +/// for (i = 0; i != 10; i += 1) { } +/// } +/// } +/// +/// // Diagnostics produced by a parser could be applied to produce an annotated output like: +/// """ +/// 1 │ class Iterator: { +/// │ ╰─ error: expected type in inherited type +/// 2 │ var counter = 0 +/// 3 │ var wasCounterCalled: Bool { counter > 0 } +/// ┆ +/// 9 │ // Description of the `loopOver` function +/// 10 │ func loopOver() { +/// 11 │ for (i = 0; i != 10; i += 1) { } +/// │ ~~~~~~~~~~~~~~~~~~~~~~~~ +/// │ │ │ ╰─ error: expected ')' to end tuple pattern +/// │ │ ╰─ note: to match this opening '(' +/// │ ╰─ error: C-style for statement has been removed in Swift 3 +/// 12 │ } +/// 13 │ } +/// """ +/// ``` +/// +/// ### Motivation +/// +/// This formatter enables better development practices by: +/// +/// - **Improving Debugging**: Diagnostic messages, including errors, warnings, notes, fix-its, highlights, and remarks, +/// are aligned in context to the source code, making it easier to understand what needs to be corrected or considered. +/// +/// - **Enhanced Readability**: The formatter enriches the source code with line numbers, +/// annotations, and optional text suffixes, providing a full picture to the developer. +/// +/// - **Flexible Customization**: Through the `DiagnosticDecorator` protocol, you can customize +/// the appearance of diagnostic output. public struct DiagnosticsFormatter { - - /// A wrapper struct for a source line, its diagnostics, and any - /// non-diagnostic text that follows the line. - private struct AnnotatedSourceLine { - var diagnostics: [Diagnostic] - var sourceString: String - - /// Non-diagnostic text that is appended after this source line. - /// - /// Suffix text can be used to provide more information following a source - /// line, such as to provide an inset source buffer for a macro expansion - /// that occurs on that line. - var suffixText: String - - /// Whether this line is free of annotations. - var isFreeOfAnnotations: Bool { - return diagnostics.isEmpty && suffixText.isEmpty - } + /// An enumeration that describes the amount of contextual lines to display around each diagnostic message. + enum ContextRange { + /// A limited number of lines around each diagnostic. + /// - Parameter Int: The number of lines before and after the diagnostic message to display. + case limited(Int) + + /// The entire source code along with the diagnostics. + /// Useful for getting a full overview, but could result in a large output. + case full } - /// Number of lines which should be printed before and after the diagnostic message - public let contextSize: Int + /// Specifies the number of contextual lines that will be displayed around each diagnostic message in the output. + /// + /// - If set to `.limited(n)`, exactly `n` lines will be displayed before and after each diagnostic message. + /// This is useful for focusing on the specific lines where the diagnostic messages are. + /// + /// - If set to `.full`, the entire source code will be displayed alongside the diagnostic messages. + /// This provides a comprehensive view but may generate a long output. + let contextRange: ContextRange - /// An instance that conforms to the ``DiagnosticDecorator`` protocol, responsible for formatting diagnostic messages. + /// Instance of a type conforming to the `DiagnosticDecorator` protocol, responsible for formatting diagnostic output. /// - /// 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. + /// The type of this property allows for flexible customization of the appearance and format of diagnostic messages, + /// buffer outlines, and code highlights. Different implementations can be used to adapt the output according to + /// user preferences or 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.diagnosticDecorator = colorize ? .ANSI : .basic - } - - public static func annotatedSource( - tree: some SyntaxProtocol, - diags: [Diagnostic], - contextSize: Int = 2, - colorize: Bool = false - ) -> String { - let formatter = DiagnosticsFormatter(contextSize: contextSize, colorize: colorize) - return formatter.annotatedSource(tree: tree, diags: diags) + /// Initializes a new `DiagnosticsFormatter` instance. + /// + /// - Parameters: + /// - contextRange: Specifies the number of contextual lines around each diagnostic message. Default is `.limited(2)`. + /// - diagnosticDecorator: The decorator used for formatting diagnostic output. Default is `BasicDiagnosticDecorator`. + init( + contextRange: ContextRange = .limited(2), + diagnosticDecorator: DiagnosticDecorator = BasicDiagnosticDecorator() + ) { + self.contextRange = contextRange + self.diagnosticDecorator = diagnosticDecorator } - /// Colorize the given source line by applying highlights from diagnostics. - private func colorizeSourceLine( - _ annotatedLine: AnnotatedSourceLine, - lineNumber: Int, - tree: some SyntaxProtocol, - sourceLocationConverter slc: SourceLocationConverter + /// Produces a fully annotated representation of source code by applying diagnostics, suffixes, and indentation to a given syntax tree. + /// + /// This function meticulously crafts an annotated source code representation, integrating diagnostics, line numbers, optional text suffixes, + /// and any lines to skip or mark as skipped. + /// + /// ### Examples + /// + /// For example, given the following `tree`: + /// ```swift + /// class Iterator: { + /// var counter = 0 + /// var wasCounterCalled: Bool { counter > 0 } + /// + /// init(counter: Int = 0) { + /// self.counter = counter + /// } + /// + /// // Description of the `loopOver` function + /// func loopOver() { + /// for (i = 🐮; i != 👩‍👩‍👦‍👦; i += 1) { } + /// } + /// } + /// ``` + /// When provided with an empty `indentString` and `suffixTexts`, and utilizing a `BasicDiagnosticDecorator`, + /// along with diagnostics generated by a parser, this function will produce the following output: + /// ```swift + /// """ + /// 1 │ class Iterator: { + /// │ ╰─ error: expected type in inherited type + /// 2 │ var counter = 0 + /// 3 │ var wasCounterCalled: Bool { counter > 0 } + /// ┆ + /// 9 │ // Description of the `loopOver` function + /// 10 │ func loopOver() { + /// 11 │ for (i = 🐮; i != 👩‍👩‍👦‍👦; i += 1) { } + /// │ │ │ ╰─ error: expected ')' to end tuple pattern + /// │ │ ╰─ note: to match this opening '(' + /// │ ╰─ error: C-style for statement has been removed in Swift 3 + /// 12 │ } + /// 13 │ } + /// """ + /// ``` + /// + /// - Parameters: + /// - tree: A syntax tree conforming to `SyntaxProtocol`, representing the source code to annotate. + /// - diagnostics: An array of `Diagnostic` instances to be annotated alongside the source lines. + /// - indentString: An optional string for the indentation at the beginning of each source line. + /// - suffixTexts: A dictionary mapping `AbsolutePosition` to strings to be used as suffix text for the source lines. + /// - sourceLocationConverter: An optional `SourceLocationConverter` object to find source locations. + /// + /// - Returns: A string containing the fully annotated source code. + public func annotatedSource( + inSyntaxTree tree: some SyntaxProtocol, + withDiagnostics diagnostics: [Diagnostic], + usingIndentString indentString: String = "", + appendingSuffixTexts suffixTexts: [AbsolutePosition: String] = [:], + employingSourceLocationConverter sourceLocationConverter: SourceLocationConverter? = nil ) -> String { - if annotatedLine.diagnostics.isEmpty { - return annotatedLine.sourceString - } + let sourceLocationConverter = sourceLocationConverter ?? SourceLocationConverter(fileName: "", tree: tree) + + // First, we need to put each line and its diagnostics and notes together. + let annotatedSourceLines = createAnnotatedLines( + fromSourceLines: sourceLocationConverter.sourceLines, + usingDiagnostics: diagnostics, + appendingSuffixTexts: suffixTexts, + withSourceLocationConverter: sourceLocationConverter + ) - // Compute the set of highlight ranges that land on this line. These - // are column ranges, sorted in order of increasing starting column, and - // with overlapping ranges merged. - let highlightRanges: [Range] = annotatedLine.diagnostics.map { - $0.highlights - }.joined().compactMap { (highlight) -> Range? in - if highlight.root != Syntax(tree) { - return nil - } + // Calculate the ranges of line numbers that should be displayed, taking into account annotations and context. + let rangesToPrint = computeLinesToPrintRanges(from: annotatedSourceLines) - let startLoc = highlight.startLocation(converter: slc, afterLeadingTrivia: true) - let startLine = startLoc.line + // Accumulate the fully annotated source files here. + var annotatedSource = "" - // Find the starting column. - let startColumn: Int - if startLine < lineNumber { - startColumn = 1 - } else if startLine == lineNumber { - startColumn = startLoc.column - } else { - return nil - } + // Keep track if a line missing char should be printed. + var hasLineBeenSkipped = false - // Find the ending column. - let endLoc = highlight.endLocation(converter: slc, afterTrailingTrivia: false) - let endLine = endLoc.line + // Maximum numbers of digits in line number mark. + let maxNumberOfDigits = String(annotatedSourceLines.count).count - let endColumn: Int - if endLine > lineNumber { - endColumn = annotatedLine.sourceString.count - } else if endLine == lineNumber { - endColumn = endLoc.column - } else { - return nil + for annotatedLine in annotatedSourceLines { + /// If the current line number is not within the 'rangesToPrint', we continue to the next line + /// and set the 'hasLineBeenSkipped' flag, so that we can later insert a marker for the skipped lines. + guard rangesToPrint.contains(where: { $0.contains(annotatedLine.sourceLineNumber) }) else { + hasLineBeenSkipped = true + continue } - if startColumn == endColumn { - return nil + /// Add a skipped line marker to indicate that lines have been omitted, but only under two conditions: + /// 1. Lines have actually been skipped ('hasLineBeenSkipped' is true). + /// 2. There's already annotated content in 'annotatedSource', meaning this isn't the start of the annotated lines. + /// This ensures that skipped line markers only appear between annotated lines. + if hasLineBeenSkipped && !annotatedSource.isEmpty { + let skippedLineMarker = diagnosticDecorator.decorateBufferOutline("┆") + let skippedLine = indentString + String(repeating: " ", count: maxNumberOfDigits) + " " + skippedLineMarker + "\n" + annotatedSource += skippedLine } + hasLineBeenSkipped = false - return startColumn..] = highlightRanges.map { highlightRange in - let startIndex = sourceStringUTF8.index(sourceStringUTF8.startIndex, offsetBy: highlightRange.lowerBound - 1) - let endIndex = sourceStringUTF8.index(startIndex, offsetBy: highlightRange.count) - return startIndex.. String { - let slc = sourceLocationConverter ?? SourceLocationConverter(fileName: "", tree: tree) + /// - sourceLines: An array of strings, each representing a source code line to be annotated. + /// - diagnostics: An array of ``Diagnostic``. + /// - suffixTexts: A dictionary mapping ``AbsolutePosition`` to strings, which are used as suffix text + /// for the source lines. The source line for each suffix is determined by the ``SourceLocationConverter``. + /// - sourceLocationConverter: A ``SourceLocationConverter`` object that is used to convert absolute positions + /// to line numbers. + /// + /// - Returns: An array of `AnnotatedSourceLine` containing the annotated source lines. + private func createAnnotatedLines( + fromSourceLines sourceLines: [String], + usingDiagnostics diagnostics: [Diagnostic], + appendingSuffixTexts suffixTexts: [AbsolutePosition: String], + withSourceLocationConverter sourceLocationConverter: SourceLocationConverter + ) -> [AnnotatedSourceLine] { + sourceLines + .enumerated() + .map { (sourceLineIndex, sourceLine) in + let sourceLineNumber = sourceLineIndex + 1 + + var diagnosticsInSourceLine: [FlattenDiagnostic] = [] + + // Append diagnostics related to the current source line + diagnosticsInSourceLine += + diagnostics + .filter { diagnostic in + diagnostic.location(converter: sourceLocationConverter).line == sourceLineNumber + } + .map(FlattenDiagnostic.diagnostic) + + // Append notes related to the current source line + diagnosticsInSourceLine += + diagnostics + .flatMap(\.notes) + .filter { note in + note.location(converter: sourceLocationConverter).line == sourceLineNumber + } + .map(FlattenDiagnostic.note) - // First, we need to put each line and its diagnostics together - var annotatedSourceLines = [AnnotatedSourceLine]() + let suffixText = + suffixTexts + .compactMap { (position, text) in + sourceLocationConverter.location(for: position).line == sourceLineNumber ? text : nil + } + .joined() - for (sourceLineIndex, sourceLine) in slc.sourceLines.enumerated() { - let diagsForLine = diags.filter { diag in - return diag.location(converter: slc).line == (sourceLineIndex + 1) + return AnnotatedSourceLine( + diagnostics: diagnosticsInSourceLine, + sourceString: sourceLine, + sourceLineNumber: sourceLineNumber, + suffixText: suffixText + ) } - let suffixText = suffixTexts.compactMap { (position, text) in - if slc.location(for: position).line == (sourceLineIndex + 1) { - return text + } + + /// Computes line number ranges that should be printed, based on the presence of annotations and the selected context. + /// + /// This function scans an array of `AnnotatedSourceLine` to determine which lines are + /// annotated with diagnostics or notes. Depending on the `context` property, this method behaves as follows: + /// + /// - `.limited(n)`: For each annotated line, a `Range` is created to + /// indicate the span of lines around the annotated line that should also be printed, limited to `n` lines before and after. + /// + /// - `.full`: A single range spanning all the lines will be returned, effectively printing the entire source code alongside diagnostics. + /// + /// - Parameter annotatedSourceLines: An array of `AnnotatedSourceLine`, each of which represents a line + /// of source code and any annotations associated with it. + /// + /// - Returns: An array of `Range`, each specifying a range of line numbers that should be + /// printed due to the presence of annotations or the context setting. + private func computeLinesToPrintRanges(from annotatedSourceLines: [AnnotatedSourceLine]) -> [Range] { + switch contextRange { + case .limited(let contextSizeLimit): + return + annotatedSourceLines + .compactMap { annotatedLine -> Range? in + let lineNumber = annotatedLine.sourceLineNumber + if annotatedLine.isFreeOfAnnotations { + return nil + } else { + return (lineNumber - contextSizeLimit)..<(lineNumber + contextSizeLimit + 1) + } } + case .full: + return [0 ..< annotatedSourceLines.count] + } + } - return nil - }.joined() + /// Decorates a line of source code with line numbers, highlighting, and indentation. + /// + /// The method performs the following tasks: + /// * It decorates the provided source line with highlights to emphasize specific parts of the code. + /// * It creates a right-aligned line number prefix, ensuring that line numbers are neatly aligned regardless of their length. + /// * It constructs the decorated source line, incorporating the line number prefix and any highlights. If there is an additional highlighted + /// line (for instance, to underline a part of the code), this line is appended below the decorated source line. + /// + /// For example, given: + /// - `annotatedSourceLine` contains a diagnostic with `foo` highlighted, + /// - `indentString` is an empty string, + /// - `maxNumberOfDigits` is 3, + /// + /// The output would look like the following: + /// ```swift + /// """ + /// 12 │ func foo(with bar: String) -> Int { + /// │ ~~~ + /// """ + /// ``` + /// + /// - Parameters: + /// - annotatedSourceLine: The source line to be decorated. + /// - tree: The syntax tree that includes the source line. + /// - sourceLocationConverter: The converter used to find source locations. + /// - indentString: The string used for indentation, enhancing the visual hierarchy of the decorated source line. + /// - maxNumberOfDigits: The maximum number of digits in line numbers, used to align line numbers properly. + /// + /// - Returns: A string containing the decorated source line. It includes the right-aligned line number, any necessary highlighting, and proper indentation. + /// If an additional highlighted line exists (for example, to underline a specific part of the code), it is appended below the source line. + private func decorateSourceLine( + _ annotatedSourceLine: AnnotatedSourceLine, + within tree: SyntaxProtocol, + using sourceLocationConverter: SourceLocationConverter, + withIndent indentString: String, + aligningTo maxNumberOfDigits: Int + ) -> String { + // Decorate source line with highlights + let (highlightedSourceCode, additionalHighlightedLine) = decorateSourceLineWithHighlights( + annotatedSourceLine: annotatedSourceLine, + inSyntaxTree: tree, + using: sourceLocationConverter + ) - annotatedSourceLines.append(AnnotatedSourceLine(diagnostics: diagsForLine, sourceString: sourceLine, suffixText: suffixText)) + // Create right-aligned line number prefix + let lineNumberString = String(annotatedSourceLine.sourceLineNumber) + let leadingSpacesForLineNumber = String(repeating: " ", count: maxNumberOfDigits - lineNumberString.count) + let colorizedLineNumberWithBar = diagnosticDecorator.decorateBufferOutline("\(lineNumberString) │") + let rightAlignedLineNumberPrefix = leadingSpacesForLineNumber + colorizedLineNumberWithBar + " " + + // Construct decorated source with highlights and line number prefix + var decoratedSourceLine = indentString + rightAlignedLineNumberPrefix + highlightedSourceCode + + // Handle and append additional highlighted line, if exists + if let additionalHighlightedLine = additionalHighlightedLine { + let leadingSpacesForAdditionalLine = String(repeating: " ", count: maxNumberOfDigits) + let colorizedBar = diagnosticDecorator.decorateBufferOutline("│") + let additionalLinePrefix = leadingSpacesForAdditionalLine + " " + colorizedBar + " " + decoratedSourceLine += indentString + additionalLinePrefix + additionalHighlightedLine } - // Only lines with diagnostic messages should be printed, but including some context - let rangesToPrint = annotatedSourceLines.enumerated().compactMap { (lineIndex, sourceLine) -> Range? in - let lineNumber = lineIndex + 1 - if !sourceLine.isFreeOfAnnotations { - return Range(uncheckedBounds: (lower: lineNumber - contextSize, upper: lineNumber + contextSize + 1)) - } - return nil + return decoratedSourceLine + } + + /// Decorates a source line with highlights based on the associated diagnostic messages. + /// + /// - Parameters: + /// - annotatedSourceLine: The source line to be highlight. + /// - tree: The syntax tree that includes the source line. + /// - sourceLocationConverter: The converter used for converting between binary offset and line/column. + /// + /// - Returns: A tuple containing: + /// - `highlightedSourceCode`: A string that includes the decorated source line. + /// - `additionalHighlightedLine`: An optional string that provides further contextual highlights. This is `nil` if there are no additional highlight line. + private func decorateSourceLineWithHighlights( + annotatedSourceLine: AnnotatedSourceLine, + inSyntaxTree tree: SyntaxProtocol, + using sourceLocationConverter: SourceLocationConverter + ) -> (highlightedSourceCode: String, additionalHighlightedLine: String?) { + if annotatedSourceLine.diagnostics.isEmpty { + return (highlightedSourceCode: annotatedSourceLine.sourceString, additionalHighlightedLine: nil) } - // Accumulate the fully annotated source files here. - var annotatedSource = "" + let highlightRanges = computeHighlightRanges( + forLine: annotatedSourceLine, + fromTree: tree, + usingSourceLocationConverter: sourceLocationConverter + ) - /// Keep track if a line missing char should be printed - var hasLineBeenSkipped = false + // Map the column ranges into index ranges within the source string itself. + let highlightIndexRanges: [Range] = + highlightRanges + .map { highlightRange in + let sourceStringUTF8 = annotatedSourceLine.sourceString.utf8 + let startIndex = sourceStringUTF8.index(sourceStringUTF8.startIndex, offsetBy: highlightRange.lowerBound - 1) + let endIndex = sourceStringUTF8.index(startIndex, offsetBy: highlightRange.count) + return startIndex..` objects. + /// These ranges are sorted and merged to provide a concise collection of highlight regions for the source code line. + /// + /// - Parameters: + /// - annotatedSourceLine: The line annotated with diagnostics. + /// - tree: The syntax tree representing the entire source file. + /// - sourceLocationConverter: The `SourceLocationConverter` used for converting between absolute and line/column positions. + /// + /// - Returns: An array of sorted and merged `Range` objects representing the highlight regions on the line. + private func computeHighlightRanges( + forLine annotatedSourceLine: AnnotatedSourceLine, + fromTree tree: some SyntaxProtocol, + usingSourceLocationConverter sourceLocationConverter: SourceLocationConverter + ) -> [Range] { + annotatedSourceLine.diagnostics + .flatMap { + switch $0 { + case let .diagnostic(diagnostic): + return diagnostic.highlights + case .note: + return [] + } } + .compactMap { (highlight) -> Range? in + if highlight.root != Syntax(tree) { + return nil + } + + let startLocation = highlight.startLocation(converter: sourceLocationConverter, afterLeadingTrivia: true) + let startLine = startLocation.line + + let startColumn: Int + if startLine < annotatedSourceLine.sourceLineNumber { + startColumn = 1 + } else if startLine == annotatedSourceLine.sourceLineNumber { + startColumn = startLocation.column + } else { + return nil + } + + let endLocation = highlight.endLocation(converter: sourceLocationConverter, afterTrailingTrivia: false) + let endLine = endLocation.line + + let endColumn: Int + if endLine > annotatedSourceLine.sourceLineNumber { + endColumn = annotatedSourceLine.sourceString.count + } else if endLine == annotatedSourceLine.sourceLineNumber { + endColumn = endLocation.column + } else { + return nil + } - let columnsWithDiagnostics = Set(annotatedLine.diagnostics.map { $0.location(converter: slc).column }) - let diagsPerColumn = Dictionary(grouping: annotatedLine.diagnostics) { diag in - diag.location(converter: slc).column - }.sorted { lhs, rhs in - lhs.key > rhs.key + if startColumn == endColumn { + return nil + } + + return startColumn.. String { + let columnsWithDiagnostics = Set(annotatedSourceLine.diagnostics.map { $0.location(using: sourceLocationConverter).column }) + let diagnosticsPerColumn = Dictionary(grouping: annotatedSourceLine.diagnostics) { $0.location(using: sourceLocationConverter).column } + .sorted { $0.key > $1.key } + + var annotations = "" + + for (column, diagnostics) in diagnosticsPerColumn { + /// Construct the string prefix to be shown before each diagnostic message. + /// The prefix starts with the 'indentString', followed by padding spaces to align the message + /// with the maximum number of digits in the source line number, and a vertical line "│". + /// Additional vertical lines or spaces are then appended based on the column locations + /// where diagnostics are present, up to the current column. + let diagnosticMessagePrefix: String = { + var message = indentString + String(repeating: " ", count: maxNumberOfDigits) + " " + diagnosticDecorator.decorateBufferOutline("│") for c in 0.. String { + let colorizedMessage = diagnosticDecorator.decorateMessage(diagnostic.message, basedOnSeverity: diagnostic.severity) + return diagnosticMessagePrefix + marker + " " + colorizedMessage + "\n" } - // Add suffix text. - annotatedSource.append(annotatedLine.suffixText) - if annotatedSource.last != "\n" { - annotatedSource.append("\n") + // Append all but the last diagnostic with the appropriate marker + for diagnostic in diagnostics.dropLast() { + let annotation = createAnnotatedSourceLine(with: diagnostic, using: "├─") + annotations += annotation } + + // Append the last diagnostic with a different marker; use force unwrapping because it always exists + let lastDiagnostic = diagnostics.last! + let finalAnnotation = createAnnotatedSourceLine(with: lastDiagnostic, using: "╰─") + annotations += finalAnnotation } - return annotatedSource + + return annotations + } +} + +extension DiagnosticsFormatter { + /// Generates a string containing source code with annotated diagnostics. + /// + /// - SeeAlso: ``annotatedSource(inSyntaxTree:withDiagnostics:usingIndentString:appendingSuffixTexts:employingSourceLocationConverter:)`` + static func annotatedSource( + inSyntaxTree tree: some SyntaxProtocol, + withDiagnostics diagnostics: [Diagnostic], + usingIndentation indentString: String = "", + appendingSuffixTexts suffixTexts: [AbsolutePosition: String] = [:], + usingSourceLocationConverter sourceLocationConverter: SourceLocationConverter? = nil, + limitedToContextRange contextRange: ContextRange = .limited(2), + withDiagnosticDecorator decorator: DiagnosticDecorator = BasicDiagnosticDecorator() + ) -> String { + let formatter = Self(contextRange: contextRange, diagnosticDecorator: decorator) + return formatter.annotatedSource( + inSyntaxTree: tree, + withDiagnostics: diagnostics, + usingIndentString: indentString, + appendingSuffixTexts: suffixTexts, + employingSourceLocationConverter: sourceLocationConverter + ) + } +} + +// MARK: - Private helper models + +/// Consolidates different types of diagnostic information, such as messages and notes, under a unified structure. +/// +/// `FlattenDiagnostic` offers a unified approach to managing and processing various forms of diagnostic information. +/// It abstracts away the differences between diagnostic messages and informational notes, allowing for seamless integration +/// and handling within the diagnostics formatting process. +private enum FlattenDiagnostic { + /// Represents a diagnostic message. + case diagnostic(Diagnostic) + + /// Represents an informational note from a diagnostic + case note(Note) + + /// Returns the message text associated with the diagnostic entity. + var message: String { + switch self { + case let .diagnostic(diagnostic): + return diagnostic.message + case let .note(note): + return note.message + } + } + + /// Specifies the severity level of the diagnostic entity. + var severity: DiagnosticSeverity { + switch self { + case let .diagnostic(diagnostic): + return diagnostic.diagMessage.severity + case .note: + return .note + } + } + + /// Location of diagnostic entity in the source code + func location(using converter: SourceLocationConverter) -> SourceLocation { + switch self { + case let .diagnostic(diagnostic): + return diagnostic.location(converter: converter) + case let .note(note): + return note.location(converter: converter) + } + } +} + +/// A representation of a single source line, potentially enriched with diagnostics or annotations. +/// +/// `AnnotatedSourceLine` encapsulates a line of source code alongside any associated diagnostics (errors, notes, etc.) +/// or annotations for enhanced code readability and insight. The diagnostics are represented by the `FlattenDiagnostic` +/// enum, which generalizes the handling of diagnostic messages and informational notes, providing a unified +/// approach to represent and process various types of diagnostic information. +/// +/// - Note: This struct is designed for internal use by the `DiagnosticsFormatter` to organize and manage +/// source lines with their related diagnostics and annotations, facilitating a clearer understanding of code issues +/// and contextual notes. +private struct AnnotatedSourceLine { + /// A collection of diagnostics associated with this line of source code. + /// + /// The diagnostics, encapsulated by instances of `FlattenDiagnostic`, offer insights into issues + /// identified at this specific location in the source. This enables quick identification and + /// resolution of problems, with `FlattenDiagnostic` providing a common interface for accessing + /// message text, severity, and location of diagnostics or informational notes. + let diagnostics: [FlattenDiagnostic] + + /// The content of the source line. + /// + /// This string contains the actual code from the source file at the specified line number, serving + /// as a reference point for diagnostics and annotations. + let sourceString: String + + /// The line number in the source file corresponding to `sourceString`. + /// + /// This value facilitates easy navigation to and within the source file, providing a clear reference + /// to where the source line is located within its broader context. + let sourceLineNumber: Int + + /// Additional text appended to the source line for extra context or information. + /// + /// This text can include annotations, explanations, or excerpts from relevant macro expansions, + /// aimed at providing further insight or clarification. + let suffixText: String + + /// Determines if the source line lacks any associated diagnostics or annotations. + /// + /// This property returns `true` when there are no diagnostics related to the line and when the + /// `suffixText` is empty, indicating that the line is clean or unannotated. It offers a quick + /// way to assess the line's status in relation to issues or annotations. + var isFreeOfAnnotations: Bool { + return diagnostics.isEmpty && suffixText.isEmpty + } +} + +// MARK: - Capabilities Layer + +// Extension providing all necessary APIs to maintain backward compatibility. In the future, +// upon committing to the new API of `DiagnosticsFormatter` and diagnostic decorators, +// all members will be marked as deprecated. +extension DiagnosticsFormatter { + public var contextSize: Int { + switch contextRange { + case .limited(let size): + return size + case .full: + return -1 + } + } + + @available(*, deprecated, message: "Store the `colorize` property passed to the initializer instead") + public var colorize: Bool { + diagnosticDecorator is ANSIDiagnosticDecorator + } + + public init(contextSize: Int = 2, colorize: Bool = false) { + self.init(contextRange: .limited(contextSize), diagnosticDecorator: colorize ? .ANSI : .basic) } - /// Print given diagnostics for a given syntax tree on the command line public func annotatedSource( tree: some SyntaxProtocol, diags: [Diagnostic] ) -> String { - return annotatedSource( - tree: tree, - diags: diags, - indentString: "", - suffixTexts: [:] + annotatedSource(inSyntaxTree: tree, withDiagnostics: diags) + } + + public static func annotatedSource( + tree: some SyntaxProtocol, + diags: [Diagnostic], + contextSize: Int = 2, + colorize: Bool = false + ) -> String { + Self.annotatedSource( + inSyntaxTree: tree, + withDiagnostics: diags, + limitedToContextRange: .limited(contextSize), + withDiagnosticDecorator: colorize ? .ANSI : .basic ) } } + +extension Sequence> { + /// Given a set of ranges that are sorted in order of nondecreasing lower + /// bound, merge any overlapping ranges to produce a sequence of + /// nonoverlapping ranges. + fileprivate func mergingOverlappingRanges() -> [Range] { + var result: [Range] = [] + + var prior: Range? = nil + for range in self { + // If this is the first range we've seen, note it as the prior and + // continue. + guard let priorRange = prior else { + prior = range + continue + } + + // If the ranges overlap, expand the prior range. + precondition(priorRange.lowerBound <= range.lowerBound) + if priorRange.overlaps(range) { + let lower = priorRange.lowerBound + let upper = Swift.max(priorRange.upperBound, range.upperBound) + prior = lower.. String { - return group.rootSourceFiles.map { rootSourceFileID in - group.annotateSource(rootSourceFileID, formatter: self, indentString: "") - }.joined(separator: "\n") + group.rootSourceFiles + .map { rootSourceFileID in + group.annotateSource(rootSourceFileID, formatter: self, indentString: "") + } + .joined(separator: "\n") } public static func annotateSources( diff --git a/Tests/SwiftDiagnosticsTest/DiagnosticTestingUtils.swift b/Tests/SwiftDiagnosticsTest/DiagnosticTestingUtils.swift index 9987643d494..e774ce9d1e1 100644 --- a/Tests/SwiftDiagnosticsTest/DiagnosticTestingUtils.swift +++ b/Tests/SwiftDiagnosticsTest/DiagnosticTestingUtils.swift @@ -83,7 +83,7 @@ struct DiagnosticDescriptor { /// - 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"), + id: MessageID = .test, message: String, severity: DiagnosticSeverity = .error, highlight: [Syntax] = [], @@ -158,6 +158,22 @@ struct NoteDescriptor { /// The textual content of the note to be displayed. let message: String + + /// Initializes a new `NoteDescriptor`. + /// + /// - Parameters: + /// - locationMarker: The marker pointing to location in source code. + /// - id: The ID associated with the note message. + /// - message: The textual content of the note to be displayed. + init( + locationMarker: LocationMarker, + id: MessageID = .test, + message: String + ) { + self.locationMarker = locationMarker + self.id = id + self.message = message + } } /// A simple implementation of the `NoteMessage` protocol for testing. @@ -168,6 +184,19 @@ struct SimpleNoteMessage: NoteMessage { /// The unique identifier for this note message. let noteID: MessageID + + /// Initializes a new `SimpleNoteMessage`. + /// + /// - Parameters: + /// - message: The textual content of the note to be displayed. + /// - noteID: The unique identifier for this note message. + init( + message: String, + noteID: MessageID = .test + ) { + self.message = message + self.noteID = noteID + } } /// A simple implementation of the `DiagnosticMessage` protocol for testing. @@ -181,6 +210,22 @@ struct SimpleDiagnosticMessage: DiagnosticMessage { /// The severity level of the diagnostic message. let severity: DiagnosticSeverity + + /// Initializes a new `SimpleDiagnosticMessage`. + /// + /// - Parameters: + /// - message: The textual content of the diagnostic message to be displayed. + /// - diagnosticID: The ID associated with the diagnostic message for categorization or referencing. + /// - severity: The severity level of the diagnostic message. + init( + message: String, + diagnosticID: MessageID = .test, + severity: DiagnosticSeverity + ) { + self.message = message + self.diagnosticID = diagnosticID + self.severity = severity + } } /// Asserts that the annotated source generated from diagnostics matches an expected annotated source. @@ -220,3 +265,7 @@ func assertAnnotated( line: line ) } + +extension MessageID { + fileprivate static var test = Self(domain: "test", id: "conjured") +} diff --git a/Tests/SwiftDiagnosticsTest/DiagnosticsFormatterTests.swift b/Tests/SwiftDiagnosticsTest/DiagnosticsFormatterTests.swift new file mode 100644 index 00000000000..6feaa570606 --- /dev/null +++ b/Tests/SwiftDiagnosticsTest/DiagnosticsFormatterTests.swift @@ -0,0 +1,280 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftSyntax +import XCTest + +final class DiagnosticsFormatterTests: XCTestCase { + func testSimpleFunctionWithIfStatementDiagnosticAppearsAtCorrectLocation() { + assertAnnotated( + markedSource: """ + func foo() -> Int { + if 1️⃣1 != 0 { + return 0 + } + return 1 + } + """, + withDiagnostics: [ + DiagnosticDescriptor( + locationMarker: "1️⃣", + message: "My message goes here!", + severity: .error + ) + ], + matches: """ + 1 │ func foo() -> Int { + 2 │ if 1 != 0 { + │ ╰─ error: My message goes here! + 3 │ return 0 + 4 │ } + + """ + ) + } + + func testClassMethodWithDefaultInitializerDiagnosticAndNoteAppearAtCorrectLocations() { + assertAnnotated( + markedSource: """ + final class Bar { + var counter = 1 + + init(counter: Int = 1) { + self.counter = counter + } + + func foo() -> Int { + if 1️⃣1 != 0 { + return 0 + } + return 2️⃣1 + } + } + """, + withDiagnostics: [ + DiagnosticDescriptor( + locationMarker: "2️⃣", + message: "Diagnostic message", + severity: .error, + noteDescriptors: [NoteDescriptor(locationMarker: "1️⃣", message: "Note message")] + ) + ], + matches: """ + 7 │ + 8 │ func foo() -> Int { + 9 │ if 1 != 0 { + │ ╰─ note: Note message + 10 │ return 0 + 11 │ } + 12 │ return 1 + │ ╰─ error: Diagnostic message + 13 │ } + 14 │ } + + """ + ) + } + + func testClassWithInitializerShowsRemarkAndNoteAtCorrectLocations() { + assertAnnotated( + markedSource: """ + final class 1️⃣Bar { + var counter = 1 + + 2️⃣init(counter: Int = 1) { + self.counter = counter + } + + func foo() -> Int { + if 1 != 0 { + return 0 + } + return 1 + } + } + """, + withDiagnostics: [ + DiagnosticDescriptor( + locationMarker: "2️⃣", + message: "Diagnostic message", + severity: .remark, + noteDescriptors: [NoteDescriptor(locationMarker: "1️⃣", message: "Note message")] + ) + ], + matches: """ + 1 │ final class Bar { + │ ╰─ note: Note message + 2 │ var counter = 1 + 3 │ + 4 │ init(counter: Int = 1) { + │ ╰─ remark: Diagnostic message + 5 │ self.counter = counter + 6 │ } + + """ + ) + } + + func testSimpleFunctionWithIfStatementMultipleNotesAndDiagnosticAppear() { + assertAnnotated( + markedSource: """ + func foo() -> Int { + if 1️⃣1 != 0 2️⃣{ + return 0 + } + return 1 + } + """, + withDiagnostics: [ + DiagnosticDescriptor( + locationMarker: "1️⃣", + message: "My message goes here!", + noteDescriptors: [ + NoteDescriptor(locationMarker: "1️⃣", message: "First message"), + NoteDescriptor(locationMarker: "1️⃣", message: "Second message"), + NoteDescriptor(locationMarker: "2️⃣", message: "Other message"), + ] + ) + ], + matches: """ + 1 │ func foo() -> Int { + 2 │ if 1 != 0 { + │ │ ╰─ note: Other message + │ ├─ error: My message goes here! + │ ├─ note: First message + │ ╰─ note: Second message + 3 │ return 0 + 4 │ } + + """ + ) + } + + func testSimpleFunctionWithIfStatementNotesAndDiagnosticAtDifferentMarkers() { + assertAnnotated( + markedSource: """ + func foo2️⃣() -> Int { + if 1️⃣1 != 0 { + return 0 + } + return 1 + } + """, + withDiagnostics: [ + DiagnosticDescriptor( + locationMarker: "1️⃣", + message: "My message goes here!", + noteDescriptors: [ + NoteDescriptor(locationMarker: "1️⃣", message: "First message"), + NoteDescriptor(locationMarker: "2️⃣", message: "Second message"), + ] + ) + ], + matches: """ + 1 │ func foo() -> Int { + │ ╰─ note: Second message + 2 │ if 1 != 0 { + │ ├─ error: My message goes here! + │ ╰─ note: First message + 3 │ return 0 + 4 │ } + + """ + ) + } + + func testBasicMacroExpansionContextMultipleMethodsMultipleDiagnosticsAndNotes() { + assertAnnotated( + markedSource: """ + extension BasicMacroExpansionContext { + /// Detach the given node, and record where it came from. + public func5️⃣ detach(_ node: Node) -> Node { + let detached = 1️⃣node.detached + detachedNodes[Syntax(detached)] = Syntax(node) + return 3️⃣detached + } + + /// Fold all operators in `node` and associate the ``KnownSourceFile`` + /// information of `node` with the original new, folded tree. + func foldAllOperators(of node: some SyntaxProtocol, with operatorTable: OperatorTable) -> Syntax { + let folded = operatorTable.foldAll(node, errorHandler: { _ in /*ignore*/ }) + if let originalSourceFile = node.root.as(SourceFileSyntax.self), + let 8️⃣newSourceFile = folded.root.as(SourceFileSyntax.self) + { + // Folding operators doesn't change the source file and its associated locations + // Record the `KnownSourceFile` information for the folded tree. + sourceFiles[4️⃣newSourceFile] 6️⃣= sourceFiles[2️⃣originalSourceFile] + } + 7️⃣return folded + } + } + """, + withDiagnostics: [ + DiagnosticDescriptor( + locationMarker: "1️⃣", + message: "My message goes here!", + noteDescriptors: [ + NoteDescriptor(locationMarker: "1️⃣", message: "First message"), + NoteDescriptor(locationMarker: "2️⃣", message: "Second message"), + NoteDescriptor(locationMarker: "2️⃣", message: "Make sure to validate source files."), + NoteDescriptor(locationMarker: "3️⃣", message: "Another message!"), + NoteDescriptor(locationMarker: "4️⃣", message: "This is related"), + NoteDescriptor(locationMarker: "5️⃣", message: "Consider refactoring this method."), + NoteDescriptor(locationMarker: "5️⃣", message: "Consider refactoring this method..."), + NoteDescriptor(locationMarker: "5️⃣", message: "Consider refactoring this method......"), + NoteDescriptor(locationMarker: "5️⃣", message: "Consider refactoring this method......."), + NoteDescriptor(locationMarker: "6️⃣", message: "Verify the type constraints."), + NoteDescriptor(locationMarker: "7️⃣", message: "Make sure to validate source files."), + NoteDescriptor(locationMarker: "8️⃣", message: "Check returned object types"), + ] + ) + ], + matches: """ + 1 │ extension BasicMacroExpansionContext { + 2 │ /// Detach the given node, and record where it came from. + 3 │ public func detach(_ node: Node) -> Node { + │ ├─ note: Consider refactoring this method. + │ ├─ note: Consider refactoring this method... + │ ├─ note: Consider refactoring this method...... + │ ╰─ note: Consider refactoring this method....... + 4 │ let detached = node.detached + │ ├─ error: My message goes here! + │ ╰─ note: First message + 5 │ detachedNodes[Syntax(detached)] = Syntax(node) + 6 │ return detached + │ ╰─ note: Another message! + 7 │ } + 8 │ + ┆ + 12 │ let folded = operatorTable.foldAll(node, errorHandler: { _ in /*ignore*/ }) + 13 │ if let originalSourceFile = node.root.as(SourceFileSyntax.self), + 14 │ let newSourceFile = folded.root.as(SourceFileSyntax.self) + │ ╰─ note: Check returned object types + 15 │ { + 16 │ // Folding operators doesn't change the source file and its associated locations + 17 │ // Record the `KnownSourceFile` information for the folded tree. + 18 │ sourceFiles[newSourceFile] = sourceFiles[originalSourceFile] + │ │ │ ├─ note: Second message + │ │ │ ╰─ note: Make sure to validate source files. + │ │ ╰─ note: Verify the type constraints. + │ ╰─ note: This is related + 19 │ } + 20 │ return folded + │ ╰─ note: Make sure to validate source files. + 21 │ } + 22 │ } + + """ + ) + } +} diff --git a/Tests/SwiftDiagnosticsTest/GroupDiagnosticsFormatterTests.swift b/Tests/SwiftDiagnosticsTest/GroupDiagnosticsFormatterTests.swift index 3c4e6fd18ae..5c9d2b5511a 100644 --- a/Tests/SwiftDiagnosticsTest/GroupDiagnosticsFormatterTests.swift +++ b/Tests/SwiftDiagnosticsTest/GroupDiagnosticsFormatterTests.swift @@ -25,23 +25,15 @@ extension GroupedDiagnostics { _ markedSource: String, displayName: String, parent: (SourceFileID, AbsolutePosition)? = nil, - diagnosticDescriptors: [DiagnosticDescriptor], - file: StaticString = #file, - line: UInt = #line - ) -> (SourceFileID, [String: AbsolutePosition]) { + diagnosticDescriptors: [DiagnosticDescriptor] + ) throws -> (SourceFileID, [String: AbsolutePosition]) { let (markers, source) = extractMarkers(markedSource) let tree = Parser.parse(source: source) 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 additionalDiagnostics = try diagnosticDescriptors.compactMap { + try $0.createDiagnostic(inSyntaxTree: tree, usingLocationMarkers: markers) } let id = addSourceFile( @@ -60,11 +52,11 @@ extension GroupedDiagnostics { } final class GroupedDiagnosticsFormatterTests: XCTestCase { - func testGroupingForMacroExpansion() { + func testGroupingForMacroExpansion() throws { var group = GroupedDiagnostics() // Main source file. - let (mainSourceID, mainSourceMarkers) = group.addTestFile( + let (mainSourceID, mainSourceMarkers) = try group.addTestFile( """ @@ -78,18 +70,23 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase { DiagnosticDescriptor( locationMarker: "1️⃣", message: "in expansion of macro 'myAssert' here", - severity: .note + severity: .note, + noteDescriptors: [ + NoteDescriptor(locationMarker: "1️⃣", message: "first additional note"), + NoteDescriptor(locationMarker: "1️⃣", message: "second additional note"), + NoteDescriptor(locationMarker: "1️⃣", message: "last additional note"), + ] ) ] ) let inExpansionNotePos = mainSourceMarkers["1️⃣"]! // Expansion source file - _ = group.addTestFile( + _ = try group.addTestFile( """ let __a = pi let __b = 3 - if !(__a 1️⃣== __b) { + if 2️⃣!(__a 1️⃣== __b) { fatalError("assertion failed: pi != 3") } """, @@ -99,7 +96,8 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase { DiagnosticDescriptor( locationMarker: "1️⃣", message: "no matching operator '==' for types 'Double' and 'Int'", - severity: .error + severity: .error, + noteDescriptors: [NoteDescriptor(locationMarker: "2️⃣", message: "in this condition element")] ) ] ) @@ -112,27 +110,32 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase { 3 │ // test 4 │ let pi = 3.14159 5 │ #myAssert(pi == 3) - │ ╰─ note: in expansion of macro 'myAssert' here + │ ├─ note: in expansion of macro 'myAssert' here + │ ├─ note: first additional note + │ ├─ note: second additional note + │ ╰─ note: last additional note ╭─── #myAssert ─────────────────────────────────────────────────────── │1 │ let __a = pi │2 │ let __b = 3 │3 │ if !(__a == __b) { - │ │ ╰─ error: no matching operator '==' for types 'Double' and 'Int' + │ │ │ ╰─ error: no matching operator '==' for types 'Double' and 'Int' + │ │ ╰─ note: in this condition element │4 │ fatalError("assertion failed: pi != 3") │5 │ } ╰───────────────────────────────────────────────────────────────────── 6 │ print("hello" - │ ╰─ error: expected ')' to end function call + │ │ ╰─ error: expected ')' to end function call + │ ╰─ note: to match this opening '(' """ ) } - func testGroupingForDoubleNestedMacroExpansion() { + func testGroupingForDoubleNestedMacroExpansion() throws { var group = GroupedDiagnostics() // Main source file. - let (mainSourceID, mainSourceMarkers) = group.addTestFile( + let (mainSourceID, mainSourceMarkers) = try group.addTestFile( """ let pi = 3.14159 1️⃣#myAssert(pi == 3) @@ -146,7 +149,7 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase { let inExpansionNotePos = mainSourceMarkers["1️⃣"]! // Outer expansion source file - let (outerExpansionSourceID, outerExpansionSourceMarkers) = group.addTestFile( + let (outerExpansionSourceID, outerExpansionSourceMarkers) = try group.addTestFile( """ let __a = pi let __b = 3 @@ -163,7 +166,7 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase { let inInnerExpansionNotePos = outerExpansionSourceMarkers["1️⃣"]! // Expansion source file - _ = group.addTestFile( + _ = try group.addTestFile( """ !(__a 1️⃣== __b) """, diff --git a/Tests/SwiftDiagnosticsTest/ParserDiagnosticsFormatterIntegrationTests.swift b/Tests/SwiftDiagnosticsTest/ParserDiagnosticsFormatterIntegrationTests.swift index eb5922a7c83..e9df2ae48ea 100644 --- a/Tests/SwiftDiagnosticsTest/ParserDiagnosticsFormatterIntegrationTests.swift +++ b/Tests/SwiftDiagnosticsTest/ParserDiagnosticsFormatterIntegrationTests.swift @@ -20,8 +20,8 @@ final class ParserDiagnosticsFormatterIntegrationTests: XCTestCase { func annotate(source: String, colorize: Bool = false) -> String { let tree = Parser.parse(source: source) - let diags = ParseDiagnosticsGenerator.diagnostics(for: tree) - return DiagnosticsFormatter.annotatedSource(tree: tree, diags: diags, colorize: colorize) + let diagnostics = ParseDiagnosticsGenerator.diagnostics(for: tree) + return DiagnosticsFormatter.annotatedSource(tree: tree, diags: diagnostics, colorize: colorize) } func testSingleDiagnostic() { @@ -54,28 +54,41 @@ final class ParserDiagnosticsFormatterIntegrationTests: XCTestCase { let source = """ var i = 1 i = 2 - i = foo( + i = 3 i = 4 - i = 5 + i = foo( i = 6 i = 7 i = 8 i = 9 i = 10 + i = 11 + i = 12 i = bar( + i = 14 + i = 15 + i = 16 + i = 17 + i = 18 """ let expectedOutput = """ - 2 │ i = 2 - 3 │ i = foo( + 3 │ i = 3 4 │ i = 4 - │ ╰─ error: expected ')' to end function call - 5 │ i = 5 + 5 │ i = foo( + │ ╰─ note: to match this opening '(' 6 │ i = 6 + │ ╰─ error: expected ')' to end function call + 7 │ i = 7 + 8 │ i = 8 ┆ - 9 │ i = 9 - 10 │ i = 10 - 11 │ i = bar( - │ ╰─ error: expected value and ')' to end function call + 11 │ i = 11 + 12 │ i = 12 + 13 │ i = bar( + │ ╰─ note: to match this opening '(' + 14 │ i = 14 + │ ╰─ error: expected ')' to end function call + 15 │ i = 15 + 16 │ i = 16 """ assertStringsEqualWithDiff(annotate(source: source), expectedOutput) @@ -121,6 +134,60 @@ final class ParserDiagnosticsFormatterIntegrationTests: XCTestCase { assertStringsEqualWithDiff(annotate(source: source, colorize: true), expectedOutput) } + func testCStyleForLoopWithEmojiVariables() { + let source = """ + for (i = 🐮; i != 👩‍👩‍👦‍👦; i += 1) { } + """ + + let expectedOutput = """ + 1 │ for (i = 🐮; i != 👩‍👩‍👦‍👦; i += 1) { } + │ │ │ ╰─ error: expected ')' to end tuple pattern + │ │ ╰─ note: to match this opening '(' + │ ╰─ error: C-style for statement has been removed in Swift 3 + + """ + + assertStringsEqualWithDiff(annotate(source: source, colorize: false), expectedOutput) + } + + func testClassDefinitionWithInvalidInheritanceAndCStyleForLoop() { + let source = """ + class Iterator: { + var counter = 0 + var wasCounterCalled: Bool { counter > 0 } + + init(counter: Int = 0) { + self.counter = counter + } + + // Description of the `loopOver` function + func loopOver() { + for (i = 🐮; i != 👩‍👩‍👦‍👦; i += 1) { } + } + } + """ + + let expectedOutput = + """ + 1 │ class Iterator: { + │ ╰─ error: expected type in inherited type + 2 │ var counter = 0 + 3 │ var wasCounterCalled: Bool { counter > 0 } + ┆ + 9 │ // Description of the `loopOver` function + 10 │ func loopOver() { + 11 │ for (i = 🐮; i != 👩‍👩‍👦‍👦; i += 1) { } + │ │ │ ╰─ error: expected ')' to end tuple pattern + │ │ ╰─ note: to match this opening '(' + │ ╰─ error: C-style for statement has been removed in Swift 3 + 12 │ } + 13 │ } + + """ + + assertStringsEqualWithDiff(annotate(source: source, colorize: false), expectedOutput) + } + func testColoringWithHighlights() { let source = """ for (i = 🐮; i != 👩‍👩‍👦‍👦; i += 1) { } @@ -128,7 +195,8 @@ final class ParserDiagnosticsFormatterIntegrationTests: XCTestCase { let expectedOutput = """ \u{001B}[0;36m1 │\u{001B}[0;0m for \u{001B}[4;39m(i\u{001B}[0;0m \u{001B}[4;39m= 🐮; i != 👩‍👩‍👦‍👦; i += 1)\u{001B}[0;0m { } - \u{001B}[0;36m│\u{001B}[0;0m │ ╰─ \u{001B}[1;31merror: \u{001B}[1;39mexpected ')' to end tuple pattern\u{001B}[0;0m + \u{001B}[0;36m│\u{001B}[0;0m │ │ ╰─ \u{001B}[1;31merror: \u{001B}[1;39mexpected ')' to end tuple pattern\u{001B}[0;0m + \u{1B}[0;36m│\u{1B}[0;0m │ ╰─ \u{1B}[1;39mnote: \u{1B}[1;39mto match this opening \'(\'\u{1B}[0;0m \u{001B}[0;36m│\u{001B}[0;0m ╰─ \u{001B}[1;31merror: \u{001B}[1;39mC-style for statement has been removed in Swift 3\u{001B}[0;0m """ @@ -136,7 +204,7 @@ final class ParserDiagnosticsFormatterIntegrationTests: XCTestCase { assertStringsEqualWithDiff(annotate(source: source, colorize: true), expectedOutput) } - func testRighParenLocation() { + func testRightParenLocation() { let source = """ let _ : Float -> Int """