Skip to content

Commit 1294448

Browse files
committed
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.
1 parent 5c57421 commit 1294448

File tree

8 files changed

+89
-8
lines changed

8 files changed

+89
-8
lines changed

Sources/SwiftDiagnostics/DiagnosticDecorators/ANSIDiagnosticDecorator.swift

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ extension DiagnosticDecorator where Self == ANSIDiagnosticDecorator {
4848
/// ```
4949
@_spi(Testing) public func decorateMessage(
5050
_ message: String,
51-
basedOnSeverity severity: DiagnosticSeverity
51+
basedOnSeverity severity: DiagnosticSeverity,
52+
category: DiagnosticCategory? = nil
5253
) -> String {
5354
let severityText: String
5455
let severityAnnotation: ANSIAnnotation
@@ -77,7 +78,24 @@ extension DiagnosticDecorator where Self == ANSIDiagnosticDecorator {
7778
resetAfterApplication: false
7879
)
7980

80-
return prefix + colorizeIfNotEmpty(message, usingAnnotation: .diagnosticText)
81+
// Append the [#CategoryName] suffix when there is a category.
82+
let categorySuffix: String
83+
if let category {
84+
// Make the category name a link to the documentation, if there is
85+
// documentation.
86+
let categoryName: String
87+
if let documentationPath = category.documentationPath {
88+
categoryName = ANSIAnnotation.hyperlink(category.name, to: documentationPath)
89+
} else {
90+
categoryName = category.name
91+
}
92+
93+
categorySuffix = " [#\(categoryName)]"
94+
} else {
95+
categorySuffix = ""
96+
}
97+
98+
return prefix + colorizeIfNotEmpty(message, usingAnnotation: .diagnosticText) + categorySuffix
8199
}
82100

83101
/// Decorates a source code buffer outline using ANSI cyan color codes.
@@ -220,4 +238,9 @@ private struct ANSIAnnotation {
220238
static var remarkText: Self {
221239
Self(color: .blue, trait: .bold)
222240
}
241+
242+
/// Forms a hyperlink to the given URL with the given text.
243+
static func hyperlink(_ text: String, to url: String) -> String {
244+
"\u{001B}]8;;\(url)\u{001B}\\\(text)\u{001B}]8;;\u{001B}\\"
245+
}
223246
}

Sources/SwiftDiagnostics/DiagnosticDecorators/BasicDiagnosticDecorator.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ extension DiagnosticDecorator where Self == BasicDiagnosticDecorator {
3434
/// - Returns: A string that combines the severity-specific prefix and the original diagnostic message.
3535
@_spi(Testing) public func decorateMessage(
3636
_ message: String,
37-
basedOnSeverity severity: DiagnosticSeverity
37+
basedOnSeverity severity: DiagnosticSeverity,
38+
category: DiagnosticCategory? = nil
3839
) -> String {
3940
let severityText: String
4041

@@ -49,7 +50,10 @@ extension DiagnosticDecorator where Self == BasicDiagnosticDecorator {
4950
severityText = "remark"
5051
}
5152

52-
return severityText + ": " + message
53+
// Append the [#CategoryName] suffix when there is a category.
54+
let categorySuffix: String = category.map { category in " [#\(category.name)]" } ?? ""
55+
56+
return severityText + ": " + message + categorySuffix
5357
}
5458

5559
/// Passes through the source code buffer outline without modification.

Sources/SwiftDiagnostics/DiagnosticDecorators/DiagnosticDecorator.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ protocol DiagnosticDecorator {
3939
///
4040
/// - Returns: A decorated version of the diagnostic message, enhanced by visual cues like color, text styles, or other markers,
4141
/// as well as a severity-specific prefix, based on its severity level.
42-
func decorateMessage(_ message: String, basedOnSeverity severity: DiagnosticSeverity) -> String
42+
func decorateMessage(_ message: String, basedOnSeverity severity: DiagnosticSeverity, category: DiagnosticCategory?) -> String
4343

4444
/// Decorates the outline of a source code buffer to visually enhance its structure.
4545
///
@@ -69,6 +69,6 @@ extension DiagnosticDecorator {
6969
///
7070
/// - Returns: A decorated version of the diagnostic message, determined by its severity level.
7171
func decorateDiagnosticMessage(_ diagnosticMessage: DiagnosticMessage) -> String {
72-
decorateMessage(diagnosticMessage.message, basedOnSeverity: diagnosticMessage.severity)
72+
decorateMessage(diagnosticMessage.message, basedOnSeverity: diagnosticMessage.severity, category: diagnosticMessage.category)
7373
}
7474
}

Sources/SwiftDiagnostics/GroupedDiagnostics.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,8 @@ extension GroupedDiagnostics {
227227
let bufferLoc = slc.location(for: rootPosition)
228228
let decoratedMessage = diagnosticDecorator.decorateMessage(
229229
"expanded code originates here",
230-
basedOnSeverity: .note
230+
basedOnSeverity: .note,
231+
category: nil
231232
)
232233
prefixString += "`- \(bufferLoc.file):\(bufferLoc.line):\(bufferLoc.column): \(decoratedMessage)\n"
233234
}

Sources/SwiftDiagnostics/Message.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,21 @@ public enum DiagnosticSeverity: Sendable, Hashable {
3333
case remark
3434
}
3535

36+
/// Describes a category of diagnostics, which covers a set of related
37+
/// diagnostics that can share documentation.
38+
public struct DiagnosticCategory: Sendable, Hashable {
39+
/// Name that identifies the category, e.g., StrictMemorySafety.
40+
public let name: String
41+
42+
/// Path to a file providing documentation documentation for this category.
43+
public let documentationPath: String?
44+
45+
public init(name: String, documentationPath: String?) {
46+
self.name = name
47+
self.documentationPath = documentationPath
48+
}
49+
}
50+
3651
/// Types conforming to this protocol represent diagnostic messages that can be
3752
/// shown to the client.
3853
public protocol DiagnosticMessage: Sendable {
@@ -43,4 +58,12 @@ public protocol DiagnosticMessage: Sendable {
4358
var diagnosticID: MessageID { get }
4459

4560
var severity: DiagnosticSeverity { get }
61+
62+
/// The category that this diagnostic belongs in.
63+
var category: DiagnosticCategory? { get }
64+
}
65+
66+
extension DiagnosticMessage {
67+
/// Diagnostic messages default to having no category.
68+
public var category: DiagnosticCategory? { nil }
4669
}

Tests/SwiftDiagnosticsTest/DiagnosticDecorators/ANSIDiagnosticDecoratorTests.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ final class ANSIDiagnosticDecoratorTests: XCTestCase {
3434

3535
let decoratedMessageForRemark = decorator.decorateMessage(message, basedOnSeverity: .remark)
3636
assertStringsEqualWithDiff(decoratedMessageForRemark, "\u{1B}[1;34mremark: \u{1B}[1;39mFile not found\u{1B}[0;0m")
37+
38+
let decoratedMessageWithCategory = decorator.decorateMessage(
39+
message,
40+
basedOnSeverity: .error,
41+
category: DiagnosticCategory(name: "Filesystem", documentationPath: "http://www.swift.org")
42+
)
43+
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}\\]")
3744
}
3845

3946
func testDecorateMessageWithEmptyMessage() {

Tests/SwiftDiagnosticsTest/DiagnosticDecorators/BasicDiagnosticDecoratorTests.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ final class BasicDiagnosticDecoratorTests: XCTestCase {
3434

3535
let decoratedMessageForRemark = decorator.decorateMessage(message, basedOnSeverity: .remark)
3636
assertStringsEqualWithDiff(decoratedMessageForRemark, "remark: File not found")
37+
38+
let decoratedMessageWithCategory = decorator.decorateMessage(
39+
message,
40+
basedOnSeverity: .error,
41+
category: DiagnosticCategory(name: "Filesystem", documentationPath: "http://www.swift.org")
42+
)
43+
assertStringsEqualWithDiff(decoratedMessageWithCategory, "error: File not found [#Filesystem]")
3744
}
3845

3946
// MARK: - Decorate Buffer Outline Tests

Tests/SwiftDiagnosticsTest/DiagnosticTestingUtils.swift

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ struct DiagnosticDescriptor {
6262
/// The severity level of the diagnostic message.
6363
let severity: DiagnosticSeverity
6464

65+
/// The diagnostic category.
66+
let category: DiagnosticCategory?
67+
6568
/// The syntax elements to be highlighted for this diagnostic message.
6669
let highlight: [Syntax] // TODO: How to create an abstract model for this?
6770

@@ -86,6 +89,7 @@ struct DiagnosticDescriptor {
8689
id: MessageID = MessageID(domain: "test", id: "conjured"),
8790
message: String,
8891
severity: DiagnosticSeverity = .error,
92+
category: DiagnosticCategory? = nil,
8993
highlight: [Syntax] = [],
9094
noteDescriptors: [NoteDescriptor] = [],
9195
fixIts: [FixIt] = []
@@ -94,6 +98,7 @@ struct DiagnosticDescriptor {
9498
self.id = id
9599
self.message = message
96100
self.severity = severity
101+
self.category = category
97102
self.highlight = highlight
98103
self.noteDescriptors = noteDescriptors
99104
self.fixIts = fixIts
@@ -139,7 +144,8 @@ struct DiagnosticDescriptor {
139144
message: SimpleDiagnosticMessage(
140145
message: self.message,
141146
diagnosticID: self.id,
142-
severity: self.severity
147+
severity: self.severity,
148+
category: category
143149
),
144150
highlights: self.highlight,
145151
notes: notes,
@@ -181,6 +187,16 @@ struct SimpleDiagnosticMessage: DiagnosticMessage {
181187

182188
/// The severity level of the diagnostic message.
183189
let severity: DiagnosticSeverity
190+
191+
/// The category for this diagnostic.
192+
let category: DiagnosticCategory?
193+
194+
init(message: String, diagnosticID: MessageID, severity: DiagnosticSeverity, category: DiagnosticCategory? = nil) {
195+
self.message = message
196+
self.diagnosticID = diagnosticID
197+
self.severity = severity
198+
self.category = category
199+
}
184200
}
185201

186202
/// Asserts that the annotated source generated from diagnostics matches an expected annotated source.

0 commit comments

Comments
 (0)