Skip to content

Commit 14b3f31

Browse files
committed
Add diagnostic testing utils for enhanced diagnostics testing
Introduces `DiagnosticTestingUtils.swift`, a utility suite designed to aid in writing unit tests for `DiagnosticsFormatter` and `GroupedDiagnostics`. Highlights include: 1. `LocationMarker` Typealias: - Enhances readability and precision in location identification within AST. 2. `DiagnosticDescriptor` and `NoteDescriptor` Structs: - Offers a robust mechanism to construct and describe diagnostics and notes for testing. 3. Simple Implementations for Protocols: - `SimpleNoteMessage` and `SimpleDiagnosticMessage` for streamlined testing. 4. `assertAnnotated` Function: - Asserts that annotated source generated from diagnostics aligns with the expected output. This addition significantly bolsters the testing utilities, providing a comprehensive framework for ensuring accurate and effective diagnostics.
1 parent 4613d8f commit 14b3f31

File tree

3 files changed

+259
-38
lines changed

3 files changed

+259
-38
lines changed

Sources/SwiftDiagnostics/GroupedDiagnostics.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,8 @@ extension GroupedDiagnostics {
240240
}
241241

242242
prefixString = diagnosticDecorator.decorateBufferOutline(padding + "╭─── ") + sourceFile.displayName + " " + boxSuffix + "\n"
243-
suffixString = diagnosticDecorator.decorateBufferOutline(padding + "╰───" + String(repeating: "", count: sourceFile.displayName.count + 2)) + boxSuffix + "\n"
243+
suffixString =
244+
diagnosticDecorator.decorateBufferOutline(padding + "╰───" + String(repeating: "", count: sourceFile.displayName.count + 2)) + boxSuffix + "\n"
244245
}
245246

246247
// Render the buffer.
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import SwiftDiagnostics
14+
import SwiftParser
15+
import SwiftParserDiagnostics
16+
import SwiftSyntax
17+
import XCTest
18+
import _SwiftSyntaxTestSupport
19+
20+
/// A typealias representing a location marker.
21+
///
22+
/// This string serves to pinpoint the exact location of a particular token in the SwiftSyntax tree.
23+
/// Once the token location is identified, it can be leveraged for various test-specific operations such as inserting diagnostics, notes, or fix-its,
24+
/// or for closer examination of the syntax tree.
25+
///
26+
/// Markers are instrumental in writing unit tests that require precise location data. They are commonly represented using emojis like 1️⃣, 2️⃣, 3️⃣, etc., to improve readability.
27+
///
28+
/// ### Example
29+
///
30+
/// In the following test code snippet, the emojis 1️⃣ and 2️⃣ are used as location markers:
31+
///
32+
/// ```swift
33+
/// func foo() -> Int {
34+
/// if 1️⃣1 != 0 2️⃣{
35+
/// return 0
36+
/// }
37+
/// return 1
38+
/// }
39+
/// ```
40+
typealias LocationMarker = String
41+
42+
/// Represents a descriptor for constructing a diagnostic in testing.
43+
struct DiagnosticDescriptor {
44+
/// Represents errors that can occur while creating a `Diagnostic` instance.
45+
private struct DiagnosticCreationError: Error, LocalizedError {
46+
/// A human-readable message describing what went wrong.
47+
let message: String
48+
49+
/// A localized message describing what went wrong. Required by `LocalizedError`.
50+
var errorDescription: String? { message }
51+
}
52+
53+
/// The marker pointing to location in source code.
54+
let locationMarker: LocationMarker
55+
56+
/// The ID associated with the message, used for categorizing or referencing it.
57+
let id: MessageID
58+
59+
/// The textual content of the message to be displayed.
60+
let message: String
61+
62+
/// The severity level of the diagnostic message.
63+
let severity: DiagnosticSeverity
64+
65+
/// The syntax elements to be highlighted for this diagnostic message.
66+
let highlight: [Syntax] // TODO: How to create an abstract model for this?
67+
68+
/// Descriptors for any accompanying notes for this diagnostic message.
69+
let noteDescriptors: [NoteDescriptor]
70+
71+
/// Descriptors for any Fix-Its that can be applied for this diagnostic message.
72+
let fixIts: [FixIt] // TODO: How to create an abstract model for this?
73+
74+
/// Initializes a new `DiagnosticDescriptor`.
75+
///
76+
/// - Parameters:
77+
/// - locationMarker: The marker pointing to location in source code.
78+
/// - id: The message ID of the diagnostic.
79+
/// - message: The textual message to display for the diagnostic.
80+
/// - severity: The severity level of the diagnostic. Default is `.error`.
81+
/// - highlight: The syntax elements to be highlighted. Default is an empty array.
82+
/// - noteDescriptors: An array of note descriptors for additional context. Default is an empty array.
83+
/// - fixIts: An array of Fix-It descriptors for quick fixes. Default is an empty array.
84+
init(
85+
locationMarker: LocationMarker,
86+
id: MessageID = MessageID(domain: "test", id: "conjured"),
87+
message: String,
88+
severity: DiagnosticSeverity = .error,
89+
highlight: [Syntax] = [],
90+
noteDescriptors: [NoteDescriptor] = [],
91+
fixIts: [FixIt] = []
92+
) {
93+
self.locationMarker = locationMarker
94+
self.id = id
95+
self.message = message
96+
self.severity = severity
97+
self.highlight = highlight
98+
self.noteDescriptors = noteDescriptors
99+
self.fixIts = fixIts
100+
}
101+
102+
/// Creates a ``Diagnostic`` instance from a given ``DiagnosticDescriptor``, syntax tree, and location markers.
103+
///
104+
/// - Parameters:
105+
/// - tree: The syntax tree where the diagnostic is rooted.
106+
/// - markers: A dictionary mapping location markers to their respective offsets in the source code.
107+
///
108+
/// - Throws:
109+
/// - Error if the location marker is not found in the source code.
110+
/// - Error if a node corresponding to a given marker is not found in the syntax tree.
111+
///
112+
/// - Returns: A ``Diagnostic`` instance populated with details from the ``DiagnosticDescriptor``.
113+
func createDiagnostic(
114+
inSyntaxTree tree: some SyntaxProtocol,
115+
usingLocationMarkers markers: [LocationMarker: Int]
116+
) throws -> Diagnostic {
117+
func node(at marker: LocationMarker) throws -> Syntax {
118+
guard let markedOffset = markers[marker] else {
119+
throw DiagnosticCreationError(message: "Marker \(marker) not found in the marked source")
120+
}
121+
let markedPosition = AbsolutePosition(utf8Offset: markedOffset)
122+
guard let token = tree.token(at: markedPosition) else {
123+
throw DiagnosticCreationError(message: "Node not found at marker \(marker)")
124+
}
125+
return Syntax(token)
126+
}
127+
128+
let diagnosticNode = try node(at: self.locationMarker)
129+
130+
let notes = try self.noteDescriptors.map { noteDescriptor in
131+
Note(
132+
node: try node(at: noteDescriptor.locationMarker),
133+
message: SimpleNoteMessage(message: noteDescriptor.message, noteID: noteDescriptor.id)
134+
)
135+
}
136+
137+
return Diagnostic(
138+
node: diagnosticNode,
139+
message: SimpleDiagnosticMessage(
140+
message: self.message,
141+
diagnosticID: self.id,
142+
severity: self.severity
143+
),
144+
highlights: self.highlight,
145+
notes: notes,
146+
fixIts: self.fixIts
147+
)
148+
}
149+
}
150+
151+
/// Represents a descriptor for constructing a note message in testing.
152+
struct NoteDescriptor {
153+
/// The marker pointing to location in source code.
154+
let locationMarker: LocationMarker
155+
156+
/// The ID associated with the note message.
157+
let id: MessageID
158+
159+
/// The textual content of the note to be displayed.
160+
let message: String
161+
}
162+
163+
/// A simple implementation of the `NoteMessage` protocol for testing.
164+
/// This struct holds the message text and a fix-it ID for a note.
165+
struct SimpleNoteMessage: NoteMessage {
166+
/// The textual content of the note to be displayed.
167+
let message: String
168+
169+
/// The unique identifier for this note message.
170+
let noteID: MessageID
171+
}
172+
173+
/// A simple implementation of the `DiagnosticMessage` protocol for testing.
174+
/// This struct holds the message text, diagnostic ID, and severity for a diagnostic.
175+
struct SimpleDiagnosticMessage: DiagnosticMessage {
176+
/// The textual content of the diagnostic message to be displayed.
177+
let message: String
178+
179+
/// The ID associated with the diagnostic message for categorization or referencing.
180+
let diagnosticID: MessageID
181+
182+
/// The severity level of the diagnostic message.
183+
let severity: DiagnosticSeverity
184+
}
185+
186+
/// Asserts that the annotated source generated from diagnostics matches an expected annotated source.
187+
///
188+
/// - Parameters:
189+
/// - markedSource: The source code with location markers `LocationMarker` for diagnostics.
190+
/// - withDiagnostics: An array of diagnostic descriptors to generate diagnostics.
191+
/// - matches: The expected annotated source after applying the diagnostics.
192+
/// - file: The file in which failure occurred.
193+
/// - line: The line number on which failure occurred.
194+
func assertAnnotated(
195+
markedSource: String,
196+
withDiagnostics diagnosticDescriptors: [DiagnosticDescriptor],
197+
matches expectedAnnotatedSource: String,
198+
file: StaticString = #file,
199+
line: UInt = #line
200+
) {
201+
let (markers, source) = extractMarkers(markedSource)
202+
let tree = Parser.parse(source: source)
203+
204+
var diagnostics: [Diagnostic] = []
205+
206+
do {
207+
diagnostics = try diagnosticDescriptors.map {
208+
try $0.createDiagnostic(inSyntaxTree: tree, usingLocationMarkers: markers)
209+
}
210+
} catch {
211+
XCTFail(error.localizedDescription, file: file, line: line)
212+
}
213+
214+
let annotatedSource = DiagnosticsFormatter.annotatedSource(tree: tree, diags: diagnostics)
215+
216+
assertStringsEqualWithDiff(
217+
annotatedSource,
218+
expectedAnnotatedSource,
219+
file: file,
220+
line: line
221+
)
222+
}

Tests/SwiftDiagnosticsTest/GroupDiagnosticsFormatterTests.swift

Lines changed: 35 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,6 @@ import SwiftSyntax
1717
import XCTest
1818
import _SwiftSyntaxTestSupport
1919

20-
struct SimpleDiagnosticMessage: DiagnosticMessage {
21-
let message: String
22-
let diagnosticID: MessageID
23-
let severity: DiagnosticSeverity
24-
}
25-
26-
extension SimpleDiagnosticMessage: FixItMessage {
27-
var fixItID: MessageID { diagnosticID }
28-
}
29-
3020
extension GroupedDiagnostics {
3121
/// Add a new test file to the group, starting with marked source and using
3222
/// the markers to add any suggested extra diagnostics at the marker
@@ -35,34 +25,30 @@ extension GroupedDiagnostics {
3525
_ markedSource: String,
3626
displayName: String,
3727
parent: (SourceFileID, AbsolutePosition)? = nil,
38-
extraDiagnostics: [String: (String, DiagnosticSeverity)] = [:]
28+
diagnosticDescriptors: [DiagnosticDescriptor],
29+
file: StaticString = #file,
30+
line: UInt = #line
3931
) -> (SourceFileID, [String: AbsolutePosition]) {
40-
// Parse the source file and produce parser diagnostics.
4132
let (markers, source) = extractMarkers(markedSource)
4233
let tree = Parser.parse(source: source)
43-
var diagnostics = ParseDiagnosticsGenerator.diagnostics(for: tree)
44-
45-
// Add on any extra diagnostics provided, at their marker locations.
46-
for (marker, (message, severity)) in extraDiagnostics {
47-
let pos = AbsolutePosition(utf8Offset: markers[marker]!)
48-
let node = tree.token(at: pos)!.parent!
49-
50-
let diag = Diagnostic(
51-
node: node,
52-
message: SimpleDiagnosticMessage(
53-
message: message,
54-
diagnosticID: MessageID(domain: "test", id: "conjured"),
55-
severity: severity
56-
)
57-
)
58-
diagnostics.append(diag)
34+
35+
let parserDiagnostics = ParseDiagnosticsGenerator.diagnostics(for: tree)
36+
37+
var additionalDiagnostics: [Diagnostic] = []
38+
39+
do {
40+
additionalDiagnostics = try diagnosticDescriptors.map {
41+
try $0.createDiagnostic(inSyntaxTree: tree, usingLocationMarkers: markers)
42+
}
43+
} catch {
44+
XCTFail(error.localizedDescription, file: file, line: line)
5945
}
6046

6147
let id = addSourceFile(
6248
tree: tree,
6349
displayName: displayName,
6450
parent: parent,
65-
diagnostics: diagnostics
51+
diagnostics: parserDiagnostics + additionalDiagnostics
6652
)
6753

6854
let markersWithAbsPositions = markers.map { (marker, pos) in
@@ -88,7 +74,13 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase {
8874
print("hello"
8975
""",
9076
displayName: "main.swift",
91-
extraDiagnostics: ["1️⃣": ("in expansion of macro 'myAssert' here", .note)]
77+
diagnosticDescriptors: [
78+
DiagnosticDescriptor(
79+
locationMarker: "☺️",
80+
message: "in expansion of macro 'myAssert' here",
81+
severity: .note
82+
)
83+
]
9284
)
9385
let inExpansionNotePos = mainSourceMarkers["1️⃣"]!
9486

@@ -103,8 +95,12 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase {
10395
""",
10496
displayName: "#myAssert",
10597
parent: (mainSourceID, inExpansionNotePos),
106-
extraDiagnostics: [
107-
"1️⃣": ("no matching operator '==' for types 'Double' and 'Int'", .error)
98+
diagnosticDescriptors: [
99+
DiagnosticDescriptor(
100+
locationMarker: "1️⃣",
101+
message: "no matching operator '==' for types 'Double' and 'Int'",
102+
severity: .error
103+
)
108104
]
109105
)
110106

@@ -143,7 +139,9 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase {
143139
print("hello")
144140
""",
145141
displayName: "main.swift",
146-
extraDiagnostics: ["1️⃣": ("in expansion of macro 'myAssert' here", .note)]
142+
diagnosticDescriptors: [
143+
DiagnosticDescriptor(locationMarker: "1️⃣", message: "in expansion of macro 'myAssert' here", severity: .note)
144+
]
147145
)
148146
let inExpansionNotePos = mainSourceMarkers["1️⃣"]!
149147

@@ -158,8 +156,8 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase {
158156
""",
159157
displayName: "#myAssert",
160158
parent: (mainSourceID, inExpansionNotePos),
161-
extraDiagnostics: [
162-
"1️⃣": ("in expansion of macro 'invertedEqualityCheck' here", .note)
159+
diagnosticDescriptors: [
160+
DiagnosticDescriptor(locationMarker: "1️⃣", message: "in expansion of macro 'invertedEqualityCheck' here", severity: .note)
163161
]
164162
)
165163
let inInnerExpansionNotePos = outerExpansionSourceMarkers["1️⃣"]!
@@ -171,8 +169,8 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase {
171169
""",
172170
displayName: "#invertedEqualityCheck",
173171
parent: (outerExpansionSourceID, inInnerExpansionNotePos),
174-
extraDiagnostics: [
175-
"1️⃣": ("no matching operator '==' for types 'Double' and 'Int'", .error)
172+
diagnosticDescriptors: [
173+
DiagnosticDescriptor(locationMarker: "1️⃣", message: "no matching operator '==' for types 'Double' and 'Int'", severity: .error)
176174
]
177175
)
178176

0 commit comments

Comments
 (0)