Skip to content

Commit

Permalink
Add diagnostic testing utils for enhanced diagnostics testing
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Matejkob committed Oct 21, 2023
1 parent 6954167 commit 636556c
Show file tree
Hide file tree
Showing 3 changed files with 259 additions and 38 deletions.
3 changes: 2 additions & 1 deletion Sources/SwiftDiagnostics/GroupedDiagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,8 @@ extension GroupedDiagnostics {
}

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

// Render the buffer.
Expand Down
222 changes: 222 additions & 0 deletions Tests/SwiftDiagnosticsTest/DiagnosticTestingUtils.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 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 the list of Swift project authors
//
//===----------------------------------------------------------------------===//

import SwiftDiagnostics
import SwiftParser
import SwiftParserDiagnostics
import SwiftSyntax
import XCTest
import _SwiftSyntaxTestSupport

/// A typealias representing a location marker.
///
/// This string serves to pinpoint the exact location of a particular token in the SwiftSyntax tree.
/// Once the token location is identified, it can be leveraged for various test-specific operations such as inserting diagnostics, notes, or fix-its,
/// or for closer examination of the syntax tree.
///
/// 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.
///
/// ### Example
///
/// In the following test code snippet, the emojis 1️⃣ and 2️⃣ are used as location markers:
///
/// ```swift
/// func foo() -> Int {
/// if 1️⃣1 != 0 2️⃣{
/// return 0
/// }
/// return 1
/// }
/// ```
typealias LocationMarker = String

/// Represents a descriptor for constructing a diagnostic in testing.
struct DiagnosticDescriptor {
/// Represents errors that can occur while creating a `Diagnostic` instance.
private struct DiagnosticCreationError: Error, LocalizedError {
/// A human-readable message describing what went wrong.
let message: String

/// A localized message describing what went wrong. Required by `LocalizedError`.
var errorDescription: String? { message }
}

/// The marker pointing to location in source code.
let locationMarker: LocationMarker

/// The ID associated with the message, used for categorizing or referencing it.
let id: MessageID

/// The textual content of the message to be displayed.
let message: String

/// The severity level of the diagnostic message.
let severity: DiagnosticSeverity

/// The syntax elements to be highlighted for this diagnostic message.
let highlight: [Syntax] // TODO: How to create an abstract model for this?

/// Descriptors for any accompanying notes for this diagnostic message.
let noteDescriptors: [NoteDescriptor]

/// Descriptors for any Fix-Its that can be applied for this diagnostic message.
let fixIts: [FixIt] // TODO: How to create an abstract model for this?

/// Initializes a new `DiagnosticDescriptor`.
///
/// - Parameters:
/// - locationMarker: The marker pointing to location in source code.
/// - id: The message ID of the diagnostic.
/// - message: The textual message to display for the diagnostic.
/// - severity: The severity level of the diagnostic. Default is `.error`.
/// - highlight: The syntax elements to be highlighted. Default is an empty array.
/// - noteDescriptors: An array of note descriptors for additional context. Default is an empty array.
/// - fixIts: An array of Fix-It descriptors for quick fixes. Default is an empty array.
init(
locationMarker: LocationMarker,
id: MessageID = MessageID(domain: "test", id: "conjured"),
message: String,
severity: DiagnosticSeverity = .error,
highlight: [Syntax] = [],
noteDescriptors: [NoteDescriptor] = [],
fixIts: [FixIt] = []
) {
self.locationMarker = locationMarker
self.id = id
self.message = message
self.severity = severity
self.highlight = highlight
self.noteDescriptors = noteDescriptors
self.fixIts = fixIts
}

/// Creates a ``Diagnostic`` instance from a given ``DiagnosticDescriptor``, syntax tree, and location markers.
///
/// - Parameters:
/// - tree: The syntax tree where the diagnostic is rooted.
/// - markers: A dictionary mapping location markers to their respective offsets in the source code.
///
/// - Throws:
/// - Error if the location marker is not found in the source code.
/// - Error if a node corresponding to a given marker is not found in the syntax tree.
///
/// - Returns: A ``Diagnostic`` instance populated with details from the ``DiagnosticDescriptor``.
func createDiagnostic(
inSyntaxTree tree: some SyntaxProtocol,
usingLocationMarkers markers: [LocationMarker: Int]
) throws -> Diagnostic {
func node(at marker: LocationMarker) throws -> Syntax {
guard let markedOffset = markers[marker] else {
throw DiagnosticCreationError(message: "Marker \(marker) not found in the marked source")
}
let markedPosition = AbsolutePosition(utf8Offset: markedOffset)
guard let token = tree.token(at: markedPosition) else {
throw DiagnosticCreationError(message: "Node not found at marker \(marker)")
}
return Syntax(token)
}

let diagnosticNode = try node(at: self.locationMarker)

let notes = try self.noteDescriptors.map { noteDescriptor in
Note(
node: try node(at: noteDescriptor.locationMarker),
message: SimpleNoteMessage(message: noteDescriptor.message, noteID: noteDescriptor.id)
)
}

return Diagnostic(
node: diagnosticNode,
message: SimpleDiagnosticMessage(
message: self.message,
diagnosticID: self.id,
severity: self.severity
),
highlights: self.highlight,
notes: notes,
fixIts: self.fixIts
)
}
}

/// Represents a descriptor for constructing a note message in testing.
struct NoteDescriptor {
/// The marker pointing to location in source code.
let locationMarker: LocationMarker

/// The ID associated with the note message.
let id: MessageID

/// The textual content of the note to be displayed.
let message: String
}

/// A simple implementation of the `NoteMessage` protocol for testing.
/// This struct holds the message text and a fix-it ID for a note.
struct SimpleNoteMessage: NoteMessage {
/// The textual content of the note to be displayed.
let message: String

/// The unique identifier for this note message.
let noteID: MessageID
}

/// A simple implementation of the `DiagnosticMessage` protocol for testing.
/// This struct holds the message text, diagnostic ID, and severity for a diagnostic.
struct SimpleDiagnosticMessage: DiagnosticMessage {
/// The textual content of the diagnostic message to be displayed.
let message: String

/// The ID associated with the diagnostic message for categorization or referencing.
let diagnosticID: MessageID

/// The severity level of the diagnostic message.
let severity: DiagnosticSeverity
}

/// Asserts that the annotated source generated from diagnostics matches an expected annotated source.
///
/// - Parameters:
/// - markedSource: The source code with location markers `LocationMarker` for diagnostics.
/// - withDiagnostics: An array of diagnostic descriptors to generate diagnostics.
/// - matches: The expected annotated source after applying the diagnostics.
/// - file: The file in which failure occurred.
/// - line: The line number on which failure occurred.
func assertAnnotated(
markedSource: String,
withDiagnostics diagnosticDescriptors: [DiagnosticDescriptor],
matches expectedAnnotatedSource: String,
file: StaticString = #file,
line: UInt = #line
) {
let (markers, source) = extractMarkers(markedSource)
let tree = Parser.parse(source: source)

var diagnostics: [Diagnostic] = []

do {
diagnostics = try diagnosticDescriptors.map {
try $0.createDiagnostic(inSyntaxTree: tree, usingLocationMarkers: markers)
}
} catch {
XCTFail(error.localizedDescription, file: file, line: line)
}

let annotatedSource = DiagnosticsFormatter.annotatedSource(tree: tree, diags: diagnostics)

assertStringsEqualWithDiff(
annotatedSource,
expectedAnnotatedSource,
file: file,
line: line
)
}
72 changes: 35 additions & 37 deletions Tests/SwiftDiagnosticsTest/GroupDiagnosticsFormatterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,6 @@ import SwiftSyntax
import XCTest
import _SwiftSyntaxTestSupport

struct SimpleDiagnosticMessage: DiagnosticMessage {
let message: String
let diagnosticID: MessageID
let severity: DiagnosticSeverity
}

extension SimpleDiagnosticMessage: FixItMessage {
var fixItID: MessageID { diagnosticID }
}

extension GroupedDiagnostics {
/// Add a new test file to the group, starting with marked source and using
/// the markers to add any suggested extra diagnostics at the marker
Expand All @@ -35,34 +25,30 @@ extension GroupedDiagnostics {
_ markedSource: String,
displayName: String,
parent: (SourceFileID, AbsolutePosition)? = nil,
extraDiagnostics: [String: (String, DiagnosticSeverity)] = [:]
diagnosticDescriptors: [DiagnosticDescriptor],
file: StaticString = #file,
line: UInt = #line
) -> (SourceFileID, [String: AbsolutePosition]) {
// Parse the source file and produce parser diagnostics.
let (markers, source) = extractMarkers(markedSource)
let tree = Parser.parse(source: source)
var diagnostics = ParseDiagnosticsGenerator.diagnostics(for: tree)

// Add on any extra diagnostics provided, at their marker locations.
for (marker, (message, severity)) in extraDiagnostics {
let pos = AbsolutePosition(utf8Offset: markers[marker]!)
let node = tree.token(at: pos)!.parent!

let diag = Diagnostic(
node: node,
message: SimpleDiagnosticMessage(
message: message,
diagnosticID: MessageID(domain: "test", id: "conjured"),
severity: severity
)
)
diagnostics.append(diag)

let parserDiagnostics = ParseDiagnosticsGenerator.diagnostics(for: tree)

var additionalDiagnostics: [Diagnostic] = []

do {
additionalDiagnostics = try diagnosticDescriptors.map {
try $0.createDiagnostic(inSyntaxTree: tree, usingLocationMarkers: markers)
}
} catch {
XCTFail(error.localizedDescription, file: file, line: line)
}

let id = addSourceFile(
tree: tree,
displayName: displayName,
parent: parent,
diagnostics: diagnostics
diagnostics: parserDiagnostics + additionalDiagnostics
)

let markersWithAbsPositions = markers.map { (marker, pos) in
Expand All @@ -88,7 +74,13 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase {
print("hello"
""",
displayName: "main.swift",
extraDiagnostics: ["1️⃣": ("in expansion of macro 'myAssert' here", .note)]
diagnosticDescriptors: [
DiagnosticDescriptor(
locationMarker: "1️⃣",
message: "in expansion of macro 'myAssert' here",
severity: .note
)
]
)
let inExpansionNotePos = mainSourceMarkers["1️⃣"]!

Expand All @@ -103,8 +95,12 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase {
""",
displayName: "#myAssert",
parent: (mainSourceID, inExpansionNotePos),
extraDiagnostics: [
"1️⃣": ("no matching operator '==' for types 'Double' and 'Int'", .error)
diagnosticDescriptors: [
DiagnosticDescriptor(
locationMarker: "1️⃣",
message: "no matching operator '==' for types 'Double' and 'Int'",
severity: .error
)
]
)

Expand Down Expand Up @@ -143,7 +139,9 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase {
print("hello")
""",
displayName: "main.swift",
extraDiagnostics: ["1️⃣": ("in expansion of macro 'myAssert' here", .note)]
diagnosticDescriptors: [
DiagnosticDescriptor(locationMarker: "1️⃣", message: "in expansion of macro 'myAssert' here", severity: .note)
]
)
let inExpansionNotePos = mainSourceMarkers["1️⃣"]!

Expand All @@ -158,8 +156,8 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase {
""",
displayName: "#myAssert",
parent: (mainSourceID, inExpansionNotePos),
extraDiagnostics: [
"1️⃣": ("in expansion of macro 'invertedEqualityCheck' here", .note)
diagnosticDescriptors: [
DiagnosticDescriptor(locationMarker: "1️⃣", message: "in expansion of macro 'invertedEqualityCheck' here", severity: .note)
]
)
let inInnerExpansionNotePos = outerExpansionSourceMarkers["1️⃣"]!
Expand All @@ -171,8 +169,8 @@ final class GroupedDiagnosticsFormatterTests: XCTestCase {
""",
displayName: "#invertedEqualityCheck",
parent: (outerExpansionSourceID, inInnerExpansionNotePos),
extraDiagnostics: [
"1️⃣": ("no matching operator '==' for types 'Double' and 'Int'", .error)
diagnosticDescriptors: [
DiagnosticDescriptor(locationMarker: "1️⃣", message: "no matching operator '==' for types 'Double' and 'Int'", severity: .error)
]
)

Expand Down

0 comments on commit 636556c

Please sign in to comment.