diff --git a/Sources/SwiftDocC/Infrastructure/Diagnostics/ANSIAnnotation.swift b/Sources/SwiftDocC/Infrastructure/Diagnostics/ANSIAnnotation.swift new file mode 100644 index 0000000000..743e41e3ed --- /dev/null +++ b/Sources/SwiftDocC/Infrastructure/Diagnostics/ANSIAnnotation.swift @@ -0,0 +1,57 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 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 Swift project authors +*/ + +import Foundation + +struct ANSIAnnotation { + enum Color: UInt8 { + case normal = 0 + case red = 31 + case green = 32 + case yellow = 33 + case `default` = 39 + } + + enum Trait: UInt8 { + case normal = 0 + case bold = 1 + case italic = 3 + } + + private var color: Color + private var trait: Trait + + /// The textual representation of the annotation. + private var code: String { + "\u{001B}[\(trait.rawValue);\(color.rawValue)m" + } + + init(color: Color, trait: Trait = .normal) { + self.color = color + self.trait = trait + } + + func applied(to message: String) -> String { + "\(code)\(message)\(ANSIAnnotation.normal.code)" + } + + static var normal: ANSIAnnotation { + self.init(color: .normal, trait: .normal) + } + + /// Annotation used for highlighting source text. + static var sourceHighlight: ANSIAnnotation { + ANSIAnnotation(color: .green, trait: .bold) + } + /// Annotation used for highlighting source suggestion. + static var sourceSuggestionHighlight: ANSIAnnotation { + ANSIAnnotation(color: .default, trait: .bold) + } +} diff --git a/Sources/SwiftDocC/Infrastructure/Diagnostics/DiagnosticConsoleWriter.swift b/Sources/SwiftDocC/Infrastructure/Diagnostics/DiagnosticConsoleWriter.swift index 48d2cab77d..e454323f86 100644 --- a/Sources/SwiftDocC/Infrastructure/Diagnostics/DiagnosticConsoleWriter.swift +++ b/Sources/SwiftDocC/Infrastructure/Diagnostics/DiagnosticConsoleWriter.swift @@ -9,6 +9,7 @@ */ import Foundation +import Markdown /// Writes diagnostic messages to a text output stream. /// @@ -18,38 +19,66 @@ public final class DiagnosticConsoleWriter: DiagnosticFormattingConsumer { var outputStream: TextOutputStream public var formattingOptions: DiagnosticFormattingOptions private var diagnosticFormatter: DiagnosticConsoleFormatter + private var problems: [Problem] = [] /// Creates a new instance of this class with the provided output stream and filter level. /// - Parameter stream: The output stream to which this instance will write. /// - Parameter filterLevel: Determines what diagnostics should be printed. This filter level is inclusive, i.e. if a level of ``DiagnosticSeverity/information`` is specified, diagnostics with a severity up to and including `.information` will be printed. @available(*, deprecated, message: "Use init(_:formattingOptions:) instead") public convenience init(_ stream: TextOutputStream = LogHandle.standardError, filterLevel: DiagnosticSeverity = .warning) { - self.init(stream, formattingOptions: []) + self.init(stream, formattingOptions: [], baseURL: nil, highlight: nil) } /// Creates a new instance of this class with the provided output stream. - /// - Parameter stream: The output stream to which this instance will write. - public init(_ stream: TextOutputStream = LogHandle.standardError, formattingOptions options: DiagnosticFormattingOptions = []) { + /// - Parameters: + /// - stream: The output stream to which this instance will write. + /// - formattingOptions: The formatting options for the diagnostics. + /// - baseUrl: A url to be used as a base url when formatting diagnostic source path. + /// - highlight: Whether or not to highlight the default diagnostic formatting output. + public init( + _ stream: TextOutputStream = LogHandle.standardError, + formattingOptions options: DiagnosticFormattingOptions = [], + baseURL: URL? = nil, + highlight: Bool? = nil + ) { outputStream = stream formattingOptions = options - diagnosticFormatter = Self.makeDiagnosticFormatter(options) + diagnosticFormatter = Self.makeDiagnosticFormatter( + options, + baseURL: baseURL, + highlight: highlight ?? TerminalHelper.isConnectedToTerminal + ) } public func receive(_ problems: [Problem]) { - // Add a newline after each formatter description, including the last one. - let text = problems.map { diagnosticFormatter.formattedDescription(for: $0).appending("\n") }.joined() - outputStream.write(text) + if formattingOptions.contains(.formatConsoleOutputForTools) { + // Add a newline after each formatter description, including the last one. + let text = problems.map { diagnosticFormatter.formattedDescription(for: $0).appending("\n") }.joined() + outputStream.write(text) + } else { + self.problems.append(contentsOf: problems) + } } public func finalize() throws { - // The console writer writes each diagnostic as they are received. + if formattingOptions.contains(.formatConsoleOutputForTools) { + // For tools, the console writer writes each diagnostic as they are received. + } else { + let text = self.diagnosticFormatter.formattedDescription(for: problems) + outputStream.write(text) + } + self.diagnosticFormatter.finalize() } - private static func makeDiagnosticFormatter(_ options: DiagnosticFormattingOptions) -> DiagnosticConsoleFormatter { + private static func makeDiagnosticFormatter( + _ options: DiagnosticFormattingOptions, + baseURL: URL?, + highlight: Bool + ) -> DiagnosticConsoleFormatter { if options.contains(.formatConsoleOutputForTools) { return IDEDiagnosticConsoleFormatter(options: options) } else { - return DefaultDiagnosticConsoleFormatter(options: options) + return DefaultDiagnosticConsoleFormatter(baseUrl: baseURL, highlight: highlight, options: options) } } } @@ -63,12 +92,12 @@ extension DiagnosticConsoleWriter { } public static func formattedDescription(for problem: Problem, options: DiagnosticFormattingOptions = []) -> String { - let diagnosticFormatter = makeDiagnosticFormatter(options) + let diagnosticFormatter = makeDiagnosticFormatter(options, baseURL: nil, highlight: TerminalHelper.isConnectedToTerminal) return diagnosticFormatter.formattedDescription(for: problem) } public static func formattedDescription(for diagnostic: Diagnostic, options: DiagnosticFormattingOptions = []) -> String { - let diagnosticFormatter = makeDiagnosticFormatter(options) + let diagnosticFormatter = makeDiagnosticFormatter(options, baseURL: nil, highlight: TerminalHelper.isConnectedToTerminal) return diagnosticFormatter.formattedDescription(for: diagnostic) } } @@ -79,6 +108,7 @@ protocol DiagnosticConsoleFormatter { func formattedDescription(for problems: Problems) -> String where Problems: Sequence, Problems.Element == Problem func formattedDescription(for problem: Problem) -> String func formattedDescription(for diagnostic: Diagnostic) -> String + func finalize() } extension DiagnosticConsoleFormatter { @@ -125,6 +155,10 @@ struct IDEDiagnosticConsoleFormatter: DiagnosticConsoleFormatter { return description } + + func finalize() { + // Nothing to do after all diagnostics have been formatted. + } public func formattedDescription(for diagnostic: Diagnostic) -> String { return formattedDiagnosticSummary(diagnostic) + formattedDiagnosticDetails(diagnostic) @@ -165,15 +199,293 @@ struct IDEDiagnosticConsoleFormatter: DiagnosticConsoleFormatter { } } -// FIXME: Improve the readability for diagnostics on the command line https://github.com/apple/swift-docc/issues/496 -struct DefaultDiagnosticConsoleFormatter: DiagnosticConsoleFormatter { +// MARK: Default formatting + +final class DefaultDiagnosticConsoleFormatter: DiagnosticConsoleFormatter { var options: DiagnosticFormattingOptions + private let baseUrl: URL? + private let highlight: Bool + private var sourceLines: [URL: [String]] = [:] + + /// The number of additional lines from the source file that should be displayed both before and after the diagnostic source line. + private static let contextSize = 2 - func formattedDescription(for problem: Problem) -> String { - formattedDescription(for: problem.diagnostic) + init( + baseUrl: URL?, + highlight: Bool, + options: DiagnosticFormattingOptions + ) { + self.baseUrl = baseUrl + self.highlight = highlight + self.options = options } + func formattedDescription(for problems: Problems) -> String where Problems: Sequence, Problems.Element == Problem { + let sortedProblems = problems.sorted { lhs, rhs in + guard let lhsSource = lhs.diagnostic.source, + let rhsSource = rhs.diagnostic.source + else { return lhs.diagnostic.source == nil } + + guard let lhsRange = lhs.diagnostic.range, + let rhsRange = rhs.diagnostic.range + else { return lhsSource.path < rhsSource.path } + + if lhsSource.path == rhsSource.path { + return lhsRange.lowerBound < rhsRange.lowerBound + } else { + return lhsSource.path < rhsSource.path + } + } + + return sortedProblems.map { formattedDescription(for: $0) }.joined(separator: "\n\n") + } + + func formattedDescription(for problem: Problem) -> String { + formattedDiagnosticsSummary(for: problem.diagnostic) + + formattedDiagnosticDetails(for: problem.diagnostic) + + formattedDiagnosticSource(for: problem.diagnostic, with: problem.possibleSolutions) + } + func formattedDescription(for diagnostic: Diagnostic) -> String { - return IDEDiagnosticConsoleFormatter(options: options).formattedDescription(for: diagnostic) + formattedDescription(for: Problem(diagnostic: diagnostic)) + } + + func finalize() { + // Since the `sourceLines` could potentially be big if there were diagnostics in many large files, + // we remove the cached lines in a clean up step after all diagnostics have been formatted. + sourceLines = [:] + } +} + +extension DefaultDiagnosticConsoleFormatter { + private func formattedDiagnosticsSummary(for diagnostic: Diagnostic) -> String { + let summary = diagnostic.severity.description + ": " + diagnostic.summary + if highlight { + let ansiAnnotation = diagnostic.severity.ansiAnnotation + return ansiAnnotation.applied(to: summary) + } else { + return summary + } + } + + private func formattedDiagnosticDetails(for diagnostic: Diagnostic) -> String { + var result = "" + if let explanation = diagnostic.explanation { + result.append("\n\(explanation)") + } + + if !diagnostic.notes.isEmpty { + let formattedNotes = diagnostic.notes + .map { note in + let location = "\(formattedSourcePath(note.source)):\(note.range.lowerBound.line):\(note.range.lowerBound.column)" + return "\(location): \(note.message)" + } + .joined(separator: "\n") + result.append("\n\(formattedNotes)") + } + + return result + } + + private func formattedDiagnosticSource( + for diagnostic: Diagnostic, + with solutions: [Solution] + ) -> String { + var result = "" + + guard let url = diagnostic.source + else { return "" } + + guard let diagnosticRange = diagnostic.range + else { return "\n--> \(formattedSourcePath(url))" } + + let sourceLines = readSourceLines(url) + + guard !sourceLines.isEmpty + else { + return "\n--> \(formattedSourcePath(url)):\(diagnosticRange.lowerBound.line):\(diagnosticRange.lowerBound.column)-\(diagnosticRange.upperBound.line):\(diagnosticRange.upperBound.column)" + } + + // A range containing the source lines and some surrounding context. + let sourceRange = Range( + uncheckedBounds: ( + lower: max(1, diagnosticRange.lowerBound.line - Self.contextSize) - 1, + upper: min(sourceLines.count, diagnosticRange.upperBound.line + Self.contextSize) + ) + ) + let maxLinePrefixWidth = String(sourceRange.upperBound).count + + var suggestionsPerLocation = [SourceLocation: [String]]() + for solution in solutions { + // Solutions that requires multiple or zero replacements + // will be shown at the beginning of the diagnostic range. + let location: SourceLocation + if solution.replacements.count == 1 { + location = solution.replacements.first!.range.lowerBound + } else { + location = diagnosticRange.lowerBound + } + + suggestionsPerLocation[location, default: []].append(solution.summary) + } + + // Constructs the header for the diagnostic output. + // This header is aligned with the line prefix and includes the file path and the range of the diagnostic.\ + // + // Example: + // --> /path/to/file.md:1:10-2:20 + result.append("\n\(String(repeating: " ", count: maxLinePrefixWidth))--> ") + result.append( "\(formattedSourcePath(url)):\(diagnosticRange.lowerBound.line):\(diagnosticRange.lowerBound.column)-\(diagnosticRange.upperBound.line):\(diagnosticRange.upperBound.column)" + ) + + for (sourceLineIndex, sourceLine) in sourceLines[sourceRange].enumerated() { + let lineNumber = sourceLineIndex + sourceRange.lowerBound + 1 + let linePrefix = "\(lineNumber)".padding(toLength: maxLinePrefixWidth, withPad: " ", startingAt: 0) + + let highlightedSource = highlightSource( + sourceLine: sourceLine, + lineNumber: lineNumber, + range: diagnosticRange + ) + + let separator: String + if lineNumber >= diagnosticRange.lowerBound.line && lineNumber <= diagnosticRange.upperBound.line { + separator = "+" + } else { + separator = "|" + } + + // Adds to the header, a formatted source line containing the line number as prefix and a source line. + // A source line is contained in the diagnostic range will be highlighted. + // + // Example: + // 9 | A line outside the diagnostic range. + // 10 + A line inside the diagnostic range. + result.append("\n\(linePrefix) \(separator) \(highlightedSource)") + + var suggestionsPerColumn = [Int: [String]]() + + for (location, suggestions) in suggestionsPerLocation where location.line == lineNumber { + suggestionsPerColumn[location.column] = suggestions + } + + let sortedColumns = suggestionsPerColumn.keys.sorted(by: >) + + guard let firstColumn = sortedColumns.first else { continue } + + let suggestionLinePrefix = String(repeating: " ", count: maxLinePrefixWidth) + " |" + + // Constructs a prefix containing vertical separator at each column containing a suggestion. + // Suggestions are shown on different lines, this allows to visually connect a suggestion shown several lines below + // with the source code column. + // + // Example: + // 9 | A line outside the diagnostic range. + // 10 + A line inside the diagnostic range. + // | │ ╰─suggestion: A suggestion. + // | ╰─ suggestion: Another suggestion. + var longestPrefix = [Character](repeating: " ", count: firstColumn + 1) + for column in sortedColumns { + longestPrefix[column] = "│" + } + + for columnNumber in sortedColumns { + let columnSuggestions = suggestionsPerColumn[columnNumber, default: []] + let prefix = suggestionLinePrefix + String(longestPrefix.prefix(columnNumber)) + + for (index, suggestion) in columnSuggestions.enumerated() { + // Highlight suggestion and make sure it's displayed on a single line. + let singleLineSuggestion = suggestion.split(separator: "\n", omittingEmptySubsequences: true).joined(separator: "") + let highlightedSuggestion = highlightSuggestion("suggestion: \(singleLineSuggestion)") + + if index == columnSuggestions.count - 1 { + result.append("\n\(prefix)╰─\(highlightedSuggestion)") + } else { + result.append("\n\(prefix)├─\(highlightedSuggestion)") + } + } + } + } + + return result + } + + private func highlightSuggestion( + _ suggestion: String + ) -> String { + guard highlight + else { return suggestion } + + let suggestionAnsiAnnotation = ANSIAnnotation.sourceSuggestionHighlight + return suggestionAnsiAnnotation.applied(to: suggestion) + } + + private func highlightSource( + sourceLine: String, + lineNumber: Int, + range: SourceRange + ) -> String { + guard highlight, + lineNumber >= range.lowerBound.line && lineNumber <= range.upperBound.line, + !sourceLine.isEmpty + else { return sourceLine } + + var startColumn: Int + if lineNumber == range.lowerBound.line { + startColumn = range.lowerBound.column + } else { + startColumn = 1 + } + + var endColumn: Int + if lineNumber == range.upperBound.line { + endColumn = range.upperBound.column + } else { + endColumn = sourceLine.count + 1 + } + + let columnRange = startColumn.. [String] { + if let lines = sourceLines[url] { + return lines + } + + // TODO: Add support for also getting the source lines from the symbol graph files. + guard let content = try? String(contentsOf: url) + else { return [] } + + let lines = content.splitByNewlines + sourceLines[url] = lines + return lines + } + + private func formattedSourcePath(_ url: URL) -> String { + baseUrl.flatMap { url.relative(to: $0) }.map(\.path) ?? url.path + } +} + +private extension DiagnosticSeverity { + var ansiAnnotation: ANSIAnnotation { + switch self { + case .error: + return .init(color: .red, trait: .bold) + case .warning: + return .init(color: .yellow, trait: .bold) + case .information, .hint: + return .init(color: .default, trait: .bold) + } } } diff --git a/Sources/SwiftDocC/Infrastructure/Diagnostics/TerminalHelper.swift b/Sources/SwiftDocC/Infrastructure/Diagnostics/TerminalHelper.swift new file mode 100644 index 0000000000..dc3e33f5da --- /dev/null +++ b/Sources/SwiftDocC/Infrastructure/Diagnostics/TerminalHelper.swift @@ -0,0 +1,25 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 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 Swift project authors +*/ + +#if canImport(Darwin) +import Darwin.C +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif os(Windows) +import CRT +#endif + +enum TerminalHelper { + static var isConnectedToTerminal: Bool { + isatty(fileno(stderr)) != 0 + } +} diff --git a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift index 75635f6d96..8dd40eac6c 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift @@ -149,7 +149,12 @@ public struct ConvertAction: Action, RecreatingContext { let engine = diagnosticEngine ?? DiagnosticEngine(treatWarningsAsErrors: treatWarningsAsErrors) engine.filterLevel = filterLevel - engine.add(DiagnosticConsoleWriter(formattingOptions: formattingOptions)) + engine.add( + DiagnosticConsoleWriter( + formattingOptions: formattingOptions, + baseURL: documentationBundleURL ?? URL(string: fileManager.currentDirectoryPath) + ) + ) if let diagnosticFilePath = diagnosticFilePath { engine.add(DiagnosticFileWriter(outputPath: diagnosticFilePath)) } diff --git a/Sources/SwiftDocCUtilities/Action/Actions/IndexAction.swift b/Sources/SwiftDocCUtilities/Action/Actions/IndexAction.swift index 60d7fc0abd..4fa95d5413 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/IndexAction.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/IndexAction.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2021 Apple Inc. and the Swift project authors + Copyright (c) 2021-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 @@ -32,7 +32,7 @@ public struct IndexAction: Action { private var dataProvider: LocalFileSystemDataProvider! /// Initializes the action with the given validated options, creates or uses the given action workspace & context. - public init(documentationBundleURL: URL, outputURL:URL, bundleIdentifier: String, diagnosticEngine: DiagnosticEngine = .init()) throws + public init(documentationBundleURL: URL, outputURL: URL, bundleIdentifier: String, diagnosticEngine: DiagnosticEngine = .init()) throws { // Initialize the action context. self.rootURL = documentationBundleURL @@ -40,7 +40,7 @@ public struct IndexAction: Action { self.bundleIdentifier = bundleIdentifier self.diagnosticEngine = diagnosticEngine - self.diagnosticEngine.add(DiagnosticConsoleWriter(formattingOptions: [])) + self.diagnosticEngine.add(DiagnosticConsoleWriter(formattingOptions: [], baseURL: documentationBundleURL)) } /// Converts each eligable file from the source documentation bundle, diff --git a/Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift b/Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift index d0152ee8dd..6214dd9bac 100644 --- a/Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift +++ b/Sources/SwiftDocCUtilities/Action/Actions/TransformForStaticHostingAction.swift @@ -40,7 +40,7 @@ struct TransformForStaticHostingAction: Action { self.htmlTemplateDirectory = htmlTemplateDirectory self.fileManager = fileManager self.diagnosticEngine = diagnosticEngine - self.diagnosticEngine.add(DiagnosticConsoleWriter(formattingOptions: [])) + self.diagnosticEngine.add(DiagnosticConsoleWriter(formattingOptions: [], baseURL: documentationBundleURL)) } /// Converts each eligible file from the source archive and diff --git a/Tests/SwiftDocCTests/Diagnostics/DiagnosticConsoleWriterDefaultFormattingTest.swift b/Tests/SwiftDocCTests/Diagnostics/DiagnosticConsoleWriterDefaultFormattingTest.swift new file mode 100644 index 0000000000..3a68a11550 --- /dev/null +++ b/Tests/SwiftDocCTests/Diagnostics/DiagnosticConsoleWriterDefaultFormattingTest.swift @@ -0,0 +1,349 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 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 Swift project authors +*/ + +import XCTest +import Markdown +@testable import SwiftDocC + +class DiagnosticConsoleWriterDefaultFormattingTest: XCTestCase { + + class Logger: TextOutputStream { + var output = "" + + func write(_ string: String) { + output += string + } + } + + func testSeverityHighlight() { + let source = URL(fileURLWithPath: "/path/to/file.md") + let range = SourceLocation(line: 1, column: 8, source: source).. file.md:1:8-10:21 + """) + } + + func testDisplaysNotes() { + let source = URL(fileURLWithPath: "/path/to/file.md") + let range = SourceLocation(line: 1, column: 8, source: source).. /path/to/file.md:1:8-10:21 + """) + } + + func testDisplaysMultipleDiagnosticsSorted() { + let identifier = "org.swift.docc.test-identifier" + let firstProblem = { + let source = URL(fileURLWithPath: "/path/to/file.md") + let range = SourceLocation(line: 1, column: 8, source: source).. /path/to/file.md:1:8-10:21 + + \u{001B}[1;33mwarning: Second diagnostic summary\u{001B}[0;0m + Second diagnostic explanation + --> /path/to/file.md:12:1-12:10 + + \u{001B}[1;33mwarning: Third diagnostic summary\u{001B}[0;0m + Third diagnostic explanation + --> /path/to/other/file.md + """) + } + + func testDisplaysSource() { + let identifier = "org.swift.docc.test-identifier" + let summary = "Test diagnostic summary" + let explanation = "Test diagnostic explanation." + let baseURL = Bundle.module.url( + forResource: "TestBundle", withExtension: "docc", subdirectory: "Test Bundles")! + let source = baseURL.appendingPathComponent("TestTutorial.tutorial") + let range = SourceLocation(line: 44, column: 59, source: source).. TestTutorial.tutorial:44:59-44:138 + 42 | ut labore et dolore magna aliqua. Phasellus faucibus scelerisque eleifend donec pretium. + 43 | Ultrices dui sapien eget mi proin sed libero enim. Quis auctor elit sed vulputate mi sit amet. + 44 + This section link refers to this section itself: \u{001B}[1;32m.\u{001B}[0;0m + 45 | This is an external link to Swift documentation: [Swift Documentation](https://swift.org/documentation/). + 46 | This section link refers to the next section in this file: . + """) + } + + func testDisplaysPossibleSolutionsSummary() { + let identifier = "org.swift.docc.test-identifier" + let summary = "Test diagnostic summary" + let explanation = "Test diagnostic explanation." + let baseURL = Bundle.module.url( + forResource: "TestBundle", withExtension: "docc", subdirectory: "Test Bundles")! + let source = baseURL.appendingPathComponent("TestTutorial.tutorial") + let diagnosticRange = SourceLocation(line: 44, column: 59, source: source).. TestTutorial.tutorial:44:59-44:138 + 42 | ut labore et dolore magna aliqua. Phasellus faucibus scelerisque eleifend donec pretium. + 43 | Ultrices dui sapien eget mi proin sed libero enim. Quis auctor elit sed vulputate mi sit amet. + 44 + This section link refers to this section itself: \u{001B}[1;32m.\u{001B}[0;0m + | │ ╰─\u{001B}[1;39msuggestion: Other solution summary\u{001B}[0;0m + | ╰─\u{001B}[1;39msuggestion: Solution summary\u{001B}[0;0m + 45 | This is an external link to Swift documentation: [Swift Documentation](https://swift.org/documentation/). + 46 | This section link refers to the next section in this file: . + """) + } + + do { // Displays solution without replacement at the beginning of the diagnostic range. + let logger = Logger() + let consumer = DiagnosticConsoleWriter(logger, baseURL: baseURL, highlight: true) + + let solution = Solution(summary: "Solution summary", replacements: []) + + let problem = Problem(diagnostic: diagnostic, possibleSolutions: [solution]) + consumer.receive([problem]) + try? consumer.finalize() + + print(logger.output) + XCTAssertEqual(logger.output, """ + \u{001B}[1;33mwarning: \(summary)\u{001B}[0;0m + \(explanation) + --> TestTutorial.tutorial:44:59-44:138 + 42 | ut labore et dolore magna aliqua. Phasellus faucibus scelerisque eleifend donec pretium. + 43 | Ultrices dui sapien eget mi proin sed libero enim. Quis auctor elit sed vulputate mi sit amet. + 44 + This section link refers to this section itself: \u{001B}[1;32m.\u{001B}[0;0m + | ╰─\u{001B}[1;39msuggestion: Solution summary\u{001B}[0;0m + 45 | This is an external link to Swift documentation: [Swift Documentation](https://swift.org/documentation/). + 46 | This section link refers to the next section in this file: . + """) + } + + do { // Displays solution with many replacements at the beginning of the diagnostic range. + let logger = Logger() + let consumer = DiagnosticConsoleWriter(logger, baseURL: baseURL, highlight: true) + + let firstReplacement = Replacement( + range: SourceLocation(line: 44, column: 60, source: source).. TestTutorial.tutorial:44:59-44:138 + 42 | ut labore et dolore magna aliqua. Phasellus faucibus scelerisque eleifend donec pretium. + 43 | Ultrices dui sapien eget mi proin sed libero enim. Quis auctor elit sed vulputate mi sit amet. + 44 + This section link refers to this section itself: \u{001B}[1;32m.\u{001B}[0;0m + | ╰─\u{001B}[1;39msuggestion: Solution summary\u{001B}[0;0m + 45 | This is an external link to Swift documentation: [Swift Documentation](https://swift.org/documentation/). + 46 | This section link refers to the next section in this file: . + """) + } + } +} diff --git a/Tests/SwiftDocCTests/Diagnostics/DiagnosticConsoleWriterTests.swift b/Tests/SwiftDocCTests/Diagnostics/DiagnosticConsoleWriterTests.swift index d488431359..cc89e85bae 100644 --- a/Tests/SwiftDocCTests/Diagnostics/DiagnosticConsoleWriterTests.swift +++ b/Tests/SwiftDocCTests/Diagnostics/DiagnosticConsoleWriterTests.swift @@ -37,7 +37,7 @@ class DiagnosticConsoleWriterTests: XCTestCase { let problem = Problem(diagnostic: Diagnostic(source: nil, severity: .warning, range: nil, identifier: "org.swift.docc.tests", summary: "Test diagnostic"), possibleSolutions: []) let logger = Logger() - let consumer = DiagnosticConsoleWriter(logger, formattingOptions: []) + let consumer = DiagnosticConsoleWriter(logger, formattingOptions: [.formatConsoleOutputForTools]) XCTAssert(logger.output.isEmpty) consumer.receive([problem]) XCTAssertEqual(logger.output, "warning: Test diagnostic\n") @@ -47,7 +47,7 @@ class DiagnosticConsoleWriterTests: XCTestCase { let problem = Problem(diagnostic: Diagnostic(source: nil, severity: .warning, range: nil, identifier: "org.swift.docc.tests", summary: "Test diagnostic"), possibleSolutions: []) let logger = Logger() - let consumer = DiagnosticConsoleWriter(logger, formattingOptions: []) + let consumer = DiagnosticConsoleWriter(logger, formattingOptions: [.formatConsoleOutputForTools]) XCTAssert(logger.output.isEmpty) consumer.receive([problem, problem]) XCTAssertEqual(logger.output, """ @@ -127,28 +127,4 @@ class DiagnosticConsoleWriterTests: XCTestCase { """) } } - - func testDoesNotEmitFixits() { - let source = URL(string: "/path/to/file.md")! - let range = SourceLocation(line: 1, column: 8, source: source)...Missing", problem.diagnostic.identifier) XCTAssertEqual( - DiagnosticConsoleWriter.formattedDescription(for: problem.diagnostic), + DiagnosticConsoleWriter.formattedDescription(for: problem.diagnostic, options: .formatConsoleOutputForTools), """ error: Missing 'Child' child directive The 'Parent' directive must have exactly one 'Child' child directive @@ -87,7 +87,7 @@ class HasExactlyOneTests: XCTestCase { XCTAssertEqual(""" error: Duplicate 'Child' child directive The 'Parent' directive must have exactly one 'Child' child directive - """, DiagnosticConsoleWriter.formattedDescription(for: problems[0].diagnostic)) + """, DiagnosticConsoleWriter.formattedDescription(for: problems[0].diagnostic, options: .formatConsoleOutputForTools)) } } diff --git a/Tests/SwiftDocCTests/Semantics/General Purpose Analyses/HasOnlyKnownArgumentsTests.swift b/Tests/SwiftDocCTests/Semantics/General Purpose Analyses/HasOnlyKnownArgumentsTests.swift index 922b1fc644..de61619e5c 100644 --- a/Tests/SwiftDocCTests/Semantics/General Purpose Analyses/HasOnlyKnownArgumentsTests.swift +++ b/Tests/SwiftDocCTests/Semantics/General Purpose Analyses/HasOnlyKnownArgumentsTests.swift @@ -68,6 +68,6 @@ class HasOnlyKnownArgumentsTests: XCTestCase { XCTAssertEqual(problems.count, 1) guard let first = problems.first else { return } - XCTAssertEqual("error: Unknown argument 'baz' in Intro. These arguments are currently unused but allowed: 'bark', 'woof'.", DiagnosticConsoleWriter.formattedDescription(for: first.diagnostic)) + XCTAssertEqual("error: Unknown argument 'baz' in Intro. These arguments are currently unused but allowed: 'bark', 'woof'.", DiagnosticConsoleWriter.formattedDescription(for: first.diagnostic, options: .formatConsoleOutputForTools)) } } diff --git a/Tests/SwiftDocCTests/Semantics/General Purpose Analyses/HasOnlyKnownDirectivesTests.swift b/Tests/SwiftDocCTests/Semantics/General Purpose Analyses/HasOnlyKnownDirectivesTests.swift index a3350051b6..e2cdead3ef 100644 --- a/Tests/SwiftDocCTests/Semantics/General Purpose Analyses/HasOnlyKnownDirectivesTests.swift +++ b/Tests/SwiftDocCTests/Semantics/General Purpose Analyses/HasOnlyKnownDirectivesTests.swift @@ -121,7 +121,7 @@ class HasOnlyKnownDirectivesTests: XCTestCase { XCTAssertEqual(problems.count, 1) guard let first = problems.first else { return } - XCTAssertEqual("error: 'baz' directive is unsupported as a child of the 'dir' directive\nThese directives are allowed: 'Comment', 'bar', 'bark', 'foo', 'woof'", DiagnosticConsoleWriter.formattedDescription(for: first.diagnostic)) + XCTAssertEqual("error: 'baz' directive is unsupported as a child of the 'dir' directive\nThese directives are allowed: 'Comment', 'bar', 'bark', 'foo', 'woof'", DiagnosticConsoleWriter.formattedDescription(for: first.diagnostic, options: .formatConsoleOutputForTools)) } }