From 1294448bb5e2221d62fbf98b8f74fffff181a8d1 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Mon, 3 Mar 2025 22:32:25 -0800 Subject: [PATCH 1/4] Add an optional diagnostic category to diagnostics A diagnostic category provides a category name that is used to identify a set of related diagnostics. It can also include a documentation path to provide more information about those diagnostics, to help guide the user in resolving them. Always render a category as [#CategoryName] at the end of the diagnostic message. When we are producing colored output and there is a documentation path, make the category name a hyperlink to the documentation using the OSC 8 scheme. --- .../ANSIDiagnosticDecorator.swift | 27 +++++++++++++++++-- .../BasicDiagnosticDecorator.swift | 8 ++++-- .../DiagnosticDecorator.swift | 4 +-- .../SwiftDiagnostics/GroupedDiagnostics.swift | 3 ++- Sources/SwiftDiagnostics/Message.swift | 23 ++++++++++++++++ .../ANSIDiagnosticDecoratorTests.swift | 7 +++++ .../BasicDiagnosticDecoratorTests.swift | 7 +++++ .../DiagnosticTestingUtils.swift | 18 ++++++++++++- 8 files changed, 89 insertions(+), 8 deletions(-) diff --git a/Sources/SwiftDiagnostics/DiagnosticDecorators/ANSIDiagnosticDecorator.swift b/Sources/SwiftDiagnostics/DiagnosticDecorators/ANSIDiagnosticDecorator.swift index 0fccb83dd43..38df7e224ec 100644 --- a/Sources/SwiftDiagnostics/DiagnosticDecorators/ANSIDiagnosticDecorator.swift +++ b/Sources/SwiftDiagnostics/DiagnosticDecorators/ANSIDiagnosticDecorator.swift @@ -48,7 +48,8 @@ extension DiagnosticDecorator where Self == ANSIDiagnosticDecorator { /// ``` @_spi(Testing) public func decorateMessage( _ message: String, - basedOnSeverity severity: DiagnosticSeverity + basedOnSeverity severity: DiagnosticSeverity, + category: DiagnosticCategory? = nil ) -> String { let severityText: String let severityAnnotation: ANSIAnnotation @@ -77,7 +78,24 @@ extension DiagnosticDecorator where Self == ANSIDiagnosticDecorator { resetAfterApplication: false ) - return prefix + colorizeIfNotEmpty(message, usingAnnotation: .diagnosticText) + // Append the [#CategoryName] suffix when there is a category. + let categorySuffix: String + if let category { + // Make the category name a link to the documentation, if there is + // documentation. + let categoryName: String + if let documentationPath = category.documentationPath { + categoryName = ANSIAnnotation.hyperlink(category.name, to: documentationPath) + } else { + categoryName = category.name + } + + categorySuffix = " [#\(categoryName)]" + } else { + categorySuffix = "" + } + + return prefix + colorizeIfNotEmpty(message, usingAnnotation: .diagnosticText) + categorySuffix } /// Decorates a source code buffer outline using ANSI cyan color codes. @@ -220,4 +238,9 @@ private struct ANSIAnnotation { static var remarkText: Self { Self(color: .blue, trait: .bold) } + + /// Forms a hyperlink to the given URL with the given text. + static func hyperlink(_ text: String, to url: String) -> String { + "\u{001B}]8;;\(url)\u{001B}\\\(text)\u{001B}]8;;\u{001B}\\" + } } diff --git a/Sources/SwiftDiagnostics/DiagnosticDecorators/BasicDiagnosticDecorator.swift b/Sources/SwiftDiagnostics/DiagnosticDecorators/BasicDiagnosticDecorator.swift index be074343229..1d586d707b9 100644 --- a/Sources/SwiftDiagnostics/DiagnosticDecorators/BasicDiagnosticDecorator.swift +++ b/Sources/SwiftDiagnostics/DiagnosticDecorators/BasicDiagnosticDecorator.swift @@ -34,7 +34,8 @@ extension DiagnosticDecorator where Self == BasicDiagnosticDecorator { /// - Returns: A string that combines the severity-specific prefix and the original diagnostic message. @_spi(Testing) public func decorateMessage( _ message: String, - basedOnSeverity severity: DiagnosticSeverity + basedOnSeverity severity: DiagnosticSeverity, + category: DiagnosticCategory? = nil ) -> String { let severityText: String @@ -49,7 +50,10 @@ extension DiagnosticDecorator where Self == BasicDiagnosticDecorator { severityText = "remark" } - return severityText + ": " + message + // Append the [#CategoryName] suffix when there is a category. + let categorySuffix: String = category.map { category in " [#\(category.name)]" } ?? "" + + return severityText + ": " + message + categorySuffix } /// Passes through the source code buffer outline without modification. diff --git a/Sources/SwiftDiagnostics/DiagnosticDecorators/DiagnosticDecorator.swift b/Sources/SwiftDiagnostics/DiagnosticDecorators/DiagnosticDecorator.swift index 958e8383663..90ba5be2e7a 100644 --- a/Sources/SwiftDiagnostics/DiagnosticDecorators/DiagnosticDecorator.swift +++ b/Sources/SwiftDiagnostics/DiagnosticDecorators/DiagnosticDecorator.swift @@ -39,7 +39,7 @@ protocol DiagnosticDecorator { /// /// - 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 + func decorateMessage(_ message: String, basedOnSeverity severity: DiagnosticSeverity, category: DiagnosticCategory?) -> String /// Decorates the outline of a source code buffer to visually enhance its structure. /// @@ -69,6 +69,6 @@ extension DiagnosticDecorator { /// /// - Returns: A decorated version of the diagnostic message, determined by its severity level. func decorateDiagnosticMessage(_ diagnosticMessage: DiagnosticMessage) -> String { - decorateMessage(diagnosticMessage.message, basedOnSeverity: diagnosticMessage.severity) + decorateMessage(diagnosticMessage.message, basedOnSeverity: diagnosticMessage.severity, category: diagnosticMessage.category) } } diff --git a/Sources/SwiftDiagnostics/GroupedDiagnostics.swift b/Sources/SwiftDiagnostics/GroupedDiagnostics.swift index 92f5cbe88e7..f1f3059bc19 100644 --- a/Sources/SwiftDiagnostics/GroupedDiagnostics.swift +++ b/Sources/SwiftDiagnostics/GroupedDiagnostics.swift @@ -227,7 +227,8 @@ extension GroupedDiagnostics { let bufferLoc = slc.location(for: rootPosition) let decoratedMessage = diagnosticDecorator.decorateMessage( "expanded code originates here", - basedOnSeverity: .note + basedOnSeverity: .note, + category: nil ) prefixString += "`- \(bufferLoc.file):\(bufferLoc.line):\(bufferLoc.column): \(decoratedMessage)\n" } diff --git a/Sources/SwiftDiagnostics/Message.swift b/Sources/SwiftDiagnostics/Message.swift index 5258b794e5b..8c5179f50fc 100644 --- a/Sources/SwiftDiagnostics/Message.swift +++ b/Sources/SwiftDiagnostics/Message.swift @@ -33,6 +33,21 @@ public enum DiagnosticSeverity: Sendable, Hashable { case remark } +/// Describes a category of diagnostics, which covers a set of related +/// diagnostics that can share documentation. +public struct DiagnosticCategory: Sendable, Hashable { + /// Name that identifies the category, e.g., StrictMemorySafety. + public let name: String + + /// Path to a file providing documentation documentation for this category. + public let documentationPath: String? + + public init(name: String, documentationPath: String?) { + self.name = name + self.documentationPath = documentationPath + } +} + /// Types conforming to this protocol represent diagnostic messages that can be /// shown to the client. public protocol DiagnosticMessage: Sendable { @@ -43,4 +58,12 @@ public protocol DiagnosticMessage: Sendable { var diagnosticID: MessageID { get } var severity: DiagnosticSeverity { get } + + /// The category that this diagnostic belongs in. + var category: DiagnosticCategory? { get } +} + +extension DiagnosticMessage { + /// Diagnostic messages default to having no category. + public var category: DiagnosticCategory? { nil } } diff --git a/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/ANSIDiagnosticDecoratorTests.swift b/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/ANSIDiagnosticDecoratorTests.swift index 0d39d7ebe61..d2c4381e2ba 100644 --- a/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/ANSIDiagnosticDecoratorTests.swift +++ b/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/ANSIDiagnosticDecoratorTests.swift @@ -34,6 +34,13 @@ final class ANSIDiagnosticDecoratorTests: XCTestCase { let decoratedMessageForRemark = decorator.decorateMessage(message, basedOnSeverity: .remark) assertStringsEqualWithDiff(decoratedMessageForRemark, "\u{1B}[1;34mremark: \u{1B}[1;39mFile not found\u{1B}[0;0m") + + let decoratedMessageWithCategory = decorator.decorateMessage( + message, + basedOnSeverity: .error, + category: DiagnosticCategory(name: "Filesystem", documentationPath: "http://www.swift.org") + ) + assertStringsEqualWithDiff(decoratedMessageWithCategory, "\u{1B}[1;31merror: \u{1B}[1;39mFile not found\u{1B}[0;0m [#\u{001B}]8;;http://www.swift.org\u{001B}\\Filesystem\u{001B}]8;;\u{001B}\\]") } func testDecorateMessageWithEmptyMessage() { diff --git a/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/BasicDiagnosticDecoratorTests.swift b/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/BasicDiagnosticDecoratorTests.swift index 63d08b4ef3f..58bc5ddf292 100644 --- a/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/BasicDiagnosticDecoratorTests.swift +++ b/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/BasicDiagnosticDecoratorTests.swift @@ -34,6 +34,13 @@ final class BasicDiagnosticDecoratorTests: XCTestCase { let decoratedMessageForRemark = decorator.decorateMessage(message, basedOnSeverity: .remark) assertStringsEqualWithDiff(decoratedMessageForRemark, "remark: File not found") + + let decoratedMessageWithCategory = decorator.decorateMessage( + message, + basedOnSeverity: .error, + category: DiagnosticCategory(name: "Filesystem", documentationPath: "http://www.swift.org") + ) + assertStringsEqualWithDiff(decoratedMessageWithCategory, "error: File not found [#Filesystem]") } // MARK: - Decorate Buffer Outline Tests diff --git a/Tests/SwiftDiagnosticsTest/DiagnosticTestingUtils.swift b/Tests/SwiftDiagnosticsTest/DiagnosticTestingUtils.swift index 3639e8b4125..d8181b3f1dd 100644 --- a/Tests/SwiftDiagnosticsTest/DiagnosticTestingUtils.swift +++ b/Tests/SwiftDiagnosticsTest/DiagnosticTestingUtils.swift @@ -62,6 +62,9 @@ struct DiagnosticDescriptor { /// The severity level of the diagnostic message. let severity: DiagnosticSeverity + /// The diagnostic category. + let category: DiagnosticCategory? + /// The syntax elements to be highlighted for this diagnostic message. let highlight: [Syntax] // TODO: How to create an abstract model for this? @@ -86,6 +89,7 @@ struct DiagnosticDescriptor { id: MessageID = MessageID(domain: "test", id: "conjured"), message: String, severity: DiagnosticSeverity = .error, + category: DiagnosticCategory? = nil, highlight: [Syntax] = [], noteDescriptors: [NoteDescriptor] = [], fixIts: [FixIt] = [] @@ -94,6 +98,7 @@ struct DiagnosticDescriptor { self.id = id self.message = message self.severity = severity + self.category = category self.highlight = highlight self.noteDescriptors = noteDescriptors self.fixIts = fixIts @@ -139,7 +144,8 @@ struct DiagnosticDescriptor { message: SimpleDiagnosticMessage( message: self.message, diagnosticID: self.id, - severity: self.severity + severity: self.severity, + category: category ), highlights: self.highlight, notes: notes, @@ -181,6 +187,16 @@ struct SimpleDiagnosticMessage: DiagnosticMessage { /// The severity level of the diagnostic message. let severity: DiagnosticSeverity + + /// The category for this diagnostic. + let category: DiagnosticCategory? + + init(message: String, diagnosticID: MessageID, severity: DiagnosticSeverity, category: DiagnosticCategory? = nil) { + self.message = message + self.diagnosticID = diagnosticID + self.severity = severity + self.category = category + } } /// Asserts that the annotated source generated from diagnostics matches an expected annotated source. From e3b4abf8b9037b959f0d286daa77d99ccc347fee Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Tue, 4 Mar 2025 09:26:00 -0800 Subject: [PATCH 2/4] Rename category documentation "path" to "URL" Since we're creating a hyperlink to it, we want a URL here. --- .../DiagnosticDecorators/ANSIDiagnosticDecorator.swift | 7 +++++-- Sources/SwiftDiagnostics/Message.swift | 8 ++++---- .../ANSIDiagnosticDecoratorTests.swift | 2 +- .../BasicDiagnosticDecoratorTests.swift | 2 +- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/Sources/SwiftDiagnostics/DiagnosticDecorators/ANSIDiagnosticDecorator.swift b/Sources/SwiftDiagnostics/DiagnosticDecorators/ANSIDiagnosticDecorator.swift index 38df7e224ec..d5782b76c16 100644 --- a/Sources/SwiftDiagnostics/DiagnosticDecorators/ANSIDiagnosticDecorator.swift +++ b/Sources/SwiftDiagnostics/DiagnosticDecorators/ANSIDiagnosticDecorator.swift @@ -84,8 +84,8 @@ extension DiagnosticDecorator where Self == ANSIDiagnosticDecorator { // Make the category name a link to the documentation, if there is // documentation. let categoryName: String - if let documentationPath = category.documentationPath { - categoryName = ANSIAnnotation.hyperlink(category.name, to: documentationPath) + if let documentationURL = category.documentationURL { + categoryName = ANSIAnnotation.hyperlink(category.name, to: "\(documentationURL)") } else { categoryName = category.name } @@ -240,6 +240,9 @@ private struct ANSIAnnotation { } /// Forms a hyperlink to the given URL with the given text. + /// + /// This follows the OSC 8 standard for hyperlinks that is supported by + /// a number of different terminals. static func hyperlink(_ text: String, to url: String) -> String { "\u{001B}]8;;\(url)\u{001B}\\\(text)\u{001B}]8;;\u{001B}\\" } diff --git a/Sources/SwiftDiagnostics/Message.swift b/Sources/SwiftDiagnostics/Message.swift index 8c5179f50fc..99525084220 100644 --- a/Sources/SwiftDiagnostics/Message.swift +++ b/Sources/SwiftDiagnostics/Message.swift @@ -39,12 +39,12 @@ public struct DiagnosticCategory: Sendable, Hashable { /// Name that identifies the category, e.g., StrictMemorySafety. public let name: String - /// Path to a file providing documentation documentation for this category. - public let documentationPath: String? + /// URL providing documentation documentation for this category. + public let documentationURL: String? - public init(name: String, documentationPath: String?) { + public init(name: String, documentationURL: String?) { self.name = name - self.documentationPath = documentationPath + self.documentationURL = documentationURL } } diff --git a/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/ANSIDiagnosticDecoratorTests.swift b/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/ANSIDiagnosticDecoratorTests.swift index d2c4381e2ba..c34bc560734 100644 --- a/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/ANSIDiagnosticDecoratorTests.swift +++ b/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/ANSIDiagnosticDecoratorTests.swift @@ -38,7 +38,7 @@ final class ANSIDiagnosticDecoratorTests: XCTestCase { let decoratedMessageWithCategory = decorator.decorateMessage( message, basedOnSeverity: .error, - category: DiagnosticCategory(name: "Filesystem", documentationPath: "http://www.swift.org") + category: DiagnosticCategory(name: "Filesystem", documentationURL: "http://www.swift.org") ) assertStringsEqualWithDiff(decoratedMessageWithCategory, "\u{1B}[1;31merror: \u{1B}[1;39mFile not found\u{1B}[0;0m [#\u{001B}]8;;http://www.swift.org\u{001B}\\Filesystem\u{001B}]8;;\u{001B}\\]") } diff --git a/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/BasicDiagnosticDecoratorTests.swift b/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/BasicDiagnosticDecoratorTests.swift index 58bc5ddf292..7416138cb91 100644 --- a/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/BasicDiagnosticDecoratorTests.swift +++ b/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/BasicDiagnosticDecoratorTests.swift @@ -38,7 +38,7 @@ final class BasicDiagnosticDecoratorTests: XCTestCase { let decoratedMessageWithCategory = decorator.decorateMessage( message, basedOnSeverity: .error, - category: DiagnosticCategory(name: "Filesystem", documentationPath: "http://www.swift.org") + category: DiagnosticCategory(name: "Filesystem", documentationURL: "http://www.swift.org") ) assertStringsEqualWithDiff(decoratedMessageWithCategory, "error: File not found [#Filesystem]") } From e651bd9d7db84e6d468745fdffa5e00bd306358f Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Tue, 4 Mar 2025 13:05:17 -0800 Subject: [PATCH 3/4] Reformat --- .../DiagnosticDecorators/DiagnosticDecorator.swift | 12 ++++++++++-- .../ANSIDiagnosticDecoratorTests.swift | 5 ++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftDiagnostics/DiagnosticDecorators/DiagnosticDecorator.swift b/Sources/SwiftDiagnostics/DiagnosticDecorators/DiagnosticDecorator.swift index 90ba5be2e7a..07eac10e84f 100644 --- a/Sources/SwiftDiagnostics/DiagnosticDecorators/DiagnosticDecorator.swift +++ b/Sources/SwiftDiagnostics/DiagnosticDecorators/DiagnosticDecorator.swift @@ -39,7 +39,11 @@ protocol DiagnosticDecorator { /// /// - 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, category: DiagnosticCategory?) -> String + func decorateMessage( + _ message: String, + basedOnSeverity severity: DiagnosticSeverity, + category: DiagnosticCategory? + ) -> String /// Decorates the outline of a source code buffer to visually enhance its structure. /// @@ -69,6 +73,10 @@ extension DiagnosticDecorator { /// /// - Returns: A decorated version of the diagnostic message, determined by its severity level. func decorateDiagnosticMessage(_ diagnosticMessage: DiagnosticMessage) -> String { - decorateMessage(diagnosticMessage.message, basedOnSeverity: diagnosticMessage.severity, category: diagnosticMessage.category) + decorateMessage( + diagnosticMessage.message, + basedOnSeverity: diagnosticMessage.severity, + category: diagnosticMessage.category + ) } } diff --git a/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/ANSIDiagnosticDecoratorTests.swift b/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/ANSIDiagnosticDecoratorTests.swift index c34bc560734..3b8233dcc4c 100644 --- a/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/ANSIDiagnosticDecoratorTests.swift +++ b/Tests/SwiftDiagnosticsTest/DiagnosticDecorators/ANSIDiagnosticDecoratorTests.swift @@ -40,7 +40,10 @@ final class ANSIDiagnosticDecoratorTests: XCTestCase { basedOnSeverity: .error, category: DiagnosticCategory(name: "Filesystem", documentationURL: "http://www.swift.org") ) - assertStringsEqualWithDiff(decoratedMessageWithCategory, "\u{1B}[1;31merror: \u{1B}[1;39mFile not found\u{1B}[0;0m [#\u{001B}]8;;http://www.swift.org\u{001B}\\Filesystem\u{001B}]8;;\u{001B}\\]") + assertStringsEqualWithDiff( + decoratedMessageWithCategory, + "\u{1B}[1;31merror: \u{1B}[1;39mFile not found\u{1B}[0;0m [#\u{001B}]8;;http://www.swift.org\u{001B}\\Filesystem\u{001B}]8;;\u{001B}\\]" + ) } func testDecorateMessageWithEmptyMessage() { From d877261b933f29ec1ac18e65fe3167c76aece4d6 Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Tue, 4 Mar 2025 13:24:46 -0800 Subject: [PATCH 4/4] Release note for diagnostic category --- Release Notes/602.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Release Notes/602.md b/Release Notes/602.md index e4a072d5f0d..1841abf4a7c 100644 --- a/Release Notes/602.md +++ b/Release Notes/602.md @@ -2,6 +2,11 @@ ## New APIs +- `DiagnosticMessage` has a new optional property, `category`, that providesa category name and documentation URL for a diagnostic. + - Description: Tools often have many different diagnostics. Diagnostic categories allow tools to group several diagnostics together with documentation that can help users understand what the diagnostics mean and how to address them. This API allows diagnostics to provide this category information. The diagnostic renderer will provide the category at the end of the diagnostic message in the form `[#CategoryName]`. + - Pull Request: https://github.com/swiftlang/swift-syntax/pull/2981 + - Migration steps: None required. The new `category` property has optional type, and there is a default implementation that returns `nil`. Types that conform to `DiagnosticMessage` can choose to implement this property and provide a category when appropriate. + ## API Behavior Changes ## Deprecations