diff --git a/Package.swift b/Package.swift index 01ea46869eb..401149007a6 100644 --- a/Package.swift +++ b/Package.swift @@ -246,6 +246,11 @@ let package = Package( dependencies: ["_SwiftSyntaxTestSupport", "SwiftDiagnostics", "SwiftParser", "SwiftSyntaxMacros", "SwiftSyntaxMacroExpansion"] ), + .testTarget( + name: "SwiftSyntaxMacrosTestSupportTests", + dependencies: ["SwiftDiagnostics", "SwiftSyntax", "SwiftSyntaxMacros", "SwiftSyntaxMacrosTestSupport"] + ), + // MARK: SwiftParser .target( diff --git a/Release Notes/510.md b/Release Notes/510.md index 8ae0439576b..24400d550a5 100644 --- a/Release Notes/510.md +++ b/Release Notes/510.md @@ -47,6 +47,12 @@ - Issue: https://github.com/apple/swift-syntax/issues/2261 - Pull Request: https://github.com/apple/swift-syntax/pull/2264 +- `DiagnosticSpec.highlight` replaced by `highlights` + - Description: The use of a single string `highlight` prevented users from asserting that a macro highlighted exactly the expected set of syntax nodes. Use of `DiagnosticSpec.init(...highlight:...)` is deprecated and forwards to `DiagnosticSpec.init(...highlights:...)`. Migrating from `highlight` to `highlights` is straightforward; any uses of `DiagnosticSpec.init` which do not specify a `highlight` do not need to change, otherwise: + - If the diagnostic highlights a single node, the `highlight` string should be replaced with a single element array containing the same string without any trailing trivia, e.g., `highlight: "let "` -> `highlights: ["let"]`. + - If the diagnostic highlights multiple nodes, the `highlight` string should be replaced with an array containing an element for each highlighted node, e.g., `highlight: "struct {}"` -> `highlights: ["struct", "{}"]`. + - Pull Request: https://github.com/apple/swift-syntax/pull/2213 + ## Template - *Affected API or two word description* diff --git a/Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift b/Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift index 0a347c36238..5a3fcdbd2c8 100644 --- a/Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift +++ b/Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift @@ -128,8 +128,8 @@ public struct DiagnosticSpec { /// The expected severity of the diagnostic public let severity: DiagnosticSeverity - /// If not `nil`, the text the diagnostic is expected to highlight - public let highlight: String? + /// If not `nil`, the text fragments the diagnostic is expected to highlight + public let highlights: [String]? /// The notes that are expected to be attached to the diagnostic public let notes: [NoteSpec] @@ -141,7 +141,7 @@ public struct DiagnosticSpec { internal let originatorFile: StaticString internal let originatorLine: UInt - /// Creates a new ``DiagnosticSpec`` that describes a diagnsotic tests are expecting to be generated by a macro expansion. + /// Creates a new ``DiagnosticSpec`` that describes a diagnostic tests are expecting to be generated by a macro expansion. /// /// - Parameters: /// - id: If not `nil`, the ID, which the diagnostic is expected to have. @@ -149,7 +149,7 @@ public struct DiagnosticSpec { /// - line: The line to which the diagnostic is expected to point /// - column: The column to which the diagnostic is expected to point /// - severity: The expected severity of the diagnostic - /// - highlight: If not `nil`, the text the diagnostic is expected to highlight + /// - highlights: If not empty, the text fragments the diagnostic is expected to highlight /// - notes: The notes that are expected to be attached to the diagnostic /// - fixIts: The messages of the Fix-Its the diagnostic is expected to produce /// - originatorFile: The file at which this ``NoteSpec`` was created, so that assertion failures can be reported at its location. @@ -160,7 +160,7 @@ public struct DiagnosticSpec { line: Int, column: Int, severity: DiagnosticSeverity = .error, - highlight: String? = nil, + highlights: [String]? = nil, notes: [NoteSpec] = [], fixIts: [FixItSpec] = [], originatorFile: StaticString = #file, @@ -171,7 +171,7 @@ public struct DiagnosticSpec { self.line = line self.column = column self.severity = severity - self.highlight = highlight + self.highlights = highlights self.notes = notes self.fixIts = fixIts self.originatorFile = originatorFile @@ -179,6 +179,42 @@ public struct DiagnosticSpec { } } +extension DiagnosticSpec { + @available(*, deprecated, message: "Use highlights instead") + public var highlight: String? { + guard let highlights else { + return nil + } + return highlights.joined(separator: " ") + } + + @_disfavoredOverload + @available(*, deprecated, message: "Use init(id:message:line:column:severity:highlights:notes:fixIts:originatorFile:originatorLine:) instead") + public init( + id: MessageID? = nil, + message: String, + line: Int, + column: Int, + severity: DiagnosticSeverity = .error, + highlight: String? = nil, + notes: [NoteSpec] = [], + fixIts: [FixItSpec] = [], + originatorFile: StaticString = #file, + originatorLine: UInt = #line + ) { + self.init( + id: id, + message: message, + line: line, + column: column, + severity: severity, + highlights: highlight.map { [$0] }, + notes: notes, + fixIts: fixIts + ) + } +} + func assertDiagnostic( _ diag: Diagnostic, in expansionContext: BasicMacroExpansionContext, @@ -194,23 +230,27 @@ func assertDiagnostic( XCTAssertEqual(spec.severity, diag.diagMessage.severity, "severity does not match", file: spec.originatorFile, line: spec.originatorLine) - if let highlight = spec.highlight { - var highlightedCode = "" - highlightedCode.append(diag.highlights.first?.with(\.leadingTrivia, []).description ?? "") - for highlight in diag.highlights.dropFirst().dropLast() { - highlightedCode.append(highlight.description) - } - if diag.highlights.count > 1 { - highlightedCode.append(diag.highlights.last?.with(\.trailingTrivia, []).description ?? "") + if let highlights = spec.highlights { + if diag.highlights.count != highlights.count { + XCTFail( + """ + Expected \(highlights.count) highlights but received \(diag.highlights.count): + \(diag.highlights.map(\.trimmedDescription).joined(separator: "\n")) + """, + file: spec.originatorFile, + line: spec.originatorLine + ) + } else { + for (actual, expected) in zip(diag.highlights, highlights) { + assertStringsEqualWithDiff( + actual.trimmedDescription, + expected, + "highlight does not match", + file: spec.originatorFile, + line: spec.originatorLine + ) + } } - - assertStringsEqualWithDiff( - highlightedCode, - highlight, - "highlight does not match", - file: spec.originatorFile, - line: spec.originatorLine - ) } if diag.notes.count != spec.notes.count { XCTFail( diff --git a/Tests/SwiftSyntaxMacrosTestSupportTests/AssertionsTests.swift b/Tests/SwiftSyntaxMacrosTestSupportTests/AssertionsTests.swift new file mode 100644 index 00000000000..862f3e0f3f7 --- /dev/null +++ b/Tests/SwiftSyntaxMacrosTestSupportTests/AssertionsTests.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftSyntax +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +final class AssertionsTests: XCTestCase { + struct OnlyStruct: DiagnosticMessage { + var message = "'@NoStruct' cannot be applied to struct types" + var diagnosticID = MessageID(domain: "\(AssertionsTests.self)", id: "\(OnlyStruct.self)") + var severity = DiagnosticSeverity.error + } + + struct NoStruct: MemberMacro { + static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + if let structDecl = declaration.as(StructDeclSyntax.self) { + context.diagnose( + .init( + node: structDecl.structKeyword, + message: OnlyStruct() + ) + ) + } + return [] + } + } + + struct NoStructMultiHighlight: MemberMacro { + static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + if let structDecl = declaration.as(StructDeclSyntax.self) { + context.diagnose( + .init( + node: structDecl.structKeyword, + message: OnlyStruct(), + highlights: [ + Syntax(structDecl.structKeyword), + Syntax(structDecl.name), + ] + ) + ) + } + return [] + } + } + + func testAssertMacroExpansionIgnoresHighlightMatchingIfNil() { + assertMacroExpansion( + "@NoStruct struct S { }", + expandedSource: "struct S { }", + diagnostics: [ + .init(message: OnlyStruct().message, line: 1, column: 11) + ], + macros: ["NoStruct": NoStruct.self] + ) + } + + @available(*, deprecated) + func testAssertMacroExpansionMatchesSingleStringHighlight() { + assertMacroExpansion( + "@NoStruct struct S { }", + expandedSource: "struct S { }", + diagnostics: [ + .init(message: OnlyStruct().message, line: 1, column: 11, highlight: "struct") + ], + macros: ["NoStruct": NoStruct.self] + ) + } + + func testAssertMacroExpansionMatchesArrayHighlight() { + assertMacroExpansion( + "@NoStruct struct S { }", + expandedSource: "struct S { }", + diagnostics: [ + .init(message: OnlyStruct().message, line: 1, column: 11, highlights: ["struct", "S"]) + ], + macros: ["NoStruct": NoStructMultiHighlight.self] + ) + } +}