Skip to content

Add an optional diagnostic category to diagnostics #2981

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Release Notes/602.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 documentationURL = category.documentationURL {
categoryName = ANSIAnnotation.hyperlink(category.name, to: "\(documentationURL)")
} 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.
Expand Down Expand Up @@ -220,4 +238,12 @@ private struct ANSIAnnotation {
static var remarkText: Self {
Self(color: .blue, trait: .bold)
}

/// 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}\\"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uh, interesting. I did not know that terminals support hyperlinks. ✨

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wild, eh?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know which terminal applications support this functionality? I don't think it's universal (the VT-100 certainly didn't have the feature.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like Apple's Terminal.app doesn't support it. Allegedly Konsole doesn't either, although I don't have a copy handy.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Also, as an aside, you may want to escape both strings.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a list of terminals supporting this at https://github.com/Alhadis/OSC8-Adoption/. Terminal.app doesn't, but it doesn't blow up on it, either; it just shows the text (not the link), which is fine.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) -> String
func decorateMessage(
_ message: String,
basedOnSeverity severity: DiagnosticSeverity,
category: DiagnosticCategory?
) -> String

/// Decorates the outline of a source code buffer to visually enhance its structure.
///
Expand Down Expand Up @@ -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)
decorateMessage(
diagnosticMessage.message,
basedOnSeverity: diagnosticMessage.severity,
category: diagnosticMessage.category
)
}
}
3 changes: 2 additions & 1 deletion Sources/SwiftDiagnostics/GroupedDiagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
23 changes: 23 additions & 0 deletions Sources/SwiftDiagnostics/Message.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

/// URL providing documentation documentation for this category.
public let documentationURL: String?

public init(name: String, documentationURL: String?) {
self.name = name
self.documentationURL = documentationURL
}
}

/// Types conforming to this protocol represent diagnostic messages that can be
/// shown to the client.
public protocol DiagnosticMessage: Sendable {
Expand All @@ -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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ 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", 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}\\]"
)
}

func testDecorateMessageWithEmptyMessage() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", documentationURL: "http://www.swift.org")
)
assertStringsEqualWithDiff(decoratedMessageWithCategory, "error: File not found [#Filesystem]")
}

// MARK: - Decorate Buffer Outline Tests
Expand Down
18 changes: 17 additions & 1 deletion Tests/SwiftDiagnosticsTest/DiagnosticTestingUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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] = []
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down