diff --git a/Package.swift b/Package.swift index ec5c6f7b3be..351928bd19d 100644 --- a/Package.swift +++ b/Package.swift @@ -27,6 +27,10 @@ let package = Package( .library(name: "SwiftSyntaxMacros", targets: ["SwiftSyntaxMacros"]), .library(name: "SwiftSyntaxMacroExpansion", targets: ["SwiftSyntaxMacroExpansion"]), .library(name: "SwiftSyntaxMacrosTestSupport", targets: ["SwiftSyntaxMacrosTestSupport"]), + .library( + name: "SwiftSyntaxMacrosGenericTestSupport", + targets: ["SwiftSyntaxMacrosGenericTestSupport"] + ), ], targets: [ // MARK: - Internal helper targets @@ -40,7 +44,18 @@ let package = Package( .target( name: "_SwiftSyntaxTestSupport", - dependencies: ["SwiftBasicFormat", "SwiftSyntax", "SwiftSyntaxBuilder", "SwiftSyntaxMacroExpansion"] + dependencies: [ + "_SwiftSyntaxGenericTestSupport", + "SwiftBasicFormat", + "SwiftSyntax", + "SwiftSyntaxBuilder", + "SwiftSyntaxMacroExpansion", + ] + ), + + .target( + name: "_SwiftSyntaxGenericTestSupport", + dependencies: [] ), .testTarget( @@ -207,7 +222,19 @@ let package = Package( .target( name: "SwiftSyntaxMacrosTestSupport", dependencies: [ - "_SwiftSyntaxTestSupport", + "SwiftSyntax", + "SwiftSyntaxMacroExpansion", + "SwiftSyntaxMacros", + "SwiftSyntaxMacrosGenericTestSupport", + ] + ), + + // MARK: SwiftSyntaxMacrosGenericTestSupport + + .target( + name: "SwiftSyntaxMacrosGenericTestSupport", + dependencies: [ + "_SwiftSyntaxGenericTestSupport", "SwiftDiagnostics", "SwiftIDEUtils", "SwiftParser", diff --git a/Release Notes/600.md b/Release Notes/600.md index ee4e5e5a8c2..f0e16afdce1 100644 --- a/Release Notes/600.md +++ b/Release Notes/600.md @@ -95,6 +95,10 @@ - Description: `Range` gained a few convenience functions inspired from `ByteSourceRange`: `init(position:length:)`, `length`, `overlapsOrTouches` - Pull request: https://github.com/apple/swift-syntax/pull/2587 +- `SwiftSyntaxMacrosGenericTestSupport` + - Description: A version of the `SwiftSyntaxMacrosTestSupport` module that doesn't depend on `Foundation` or `XCTest` and can thus be used to write macro tests using `swift-testing`. Since swift-syntax can't depend on swift-testing (which would incur a circular dependency since swift-testing depends on swift-syntax), users need to manually specify a failure handler like the following, that fails the swift-testing test: `Issue.record("\($0.message)", fileID: $0.location.fileID, filePath: $0.location.filePath, line: $0.location.line, column: $0.location.column)` + - Pull request: https://github.com/apple/swift-syntax/pull/2647 + ## API Behavior Changes ## Deprecations diff --git a/Sources/SwiftSyntaxMacrosGenericTestSupport/Assertions.swift b/Sources/SwiftSyntaxMacrosGenericTestSupport/Assertions.swift new file mode 100644 index 00000000000..8f204aafbf3 --- /dev/null +++ b/Sources/SwiftSyntaxMacrosGenericTestSupport/Assertions.swift @@ -0,0 +1,607 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +#if swift(>=6.0) +import SwiftBasicFormat +public import SwiftDiagnostics +@_spi(FixItApplier) import SwiftIDEUtils +import SwiftParser +import SwiftParserDiagnostics +public import SwiftSyntax +public import SwiftSyntaxMacroExpansion +private import SwiftSyntaxMacros +private import _SwiftSyntaxGenericTestSupport +#else +import SwiftBasicFormat +import SwiftDiagnostics +@_spi(FixItApplier) import SwiftIDEUtils +import SwiftParser +import SwiftParserDiagnostics +import SwiftSyntax +import SwiftSyntaxMacroExpansion +import SwiftSyntaxMacros +import _SwiftSyntaxGenericTestSupport +#endif + +/// Defines the location at which the a test failure should be anchored. This is typically the location where the +/// assertion function is called. +public struct TestFailureLocation { + @_spi(XCTestFailureLocation) public let staticFileID: StaticString + public var fileID: String { staticFileID.description } + + @_spi(XCTestFailureLocation) public let staticFilePath: StaticString + public var filePath: String { staticFilePath.description } + + @_spi(XCTestFailureLocation) public let unsignedLine: UInt + public var line: Int { Int(unsignedLine) } + + @_spi(XCTestFailureLocation) public let unsignedColumn: UInt + public var column: Int { Int(unsignedColumn) } + + public init( + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + self.staticFileID = fileID + self.staticFilePath = filePath + self.unsignedLine = line + self.unsignedColumn = column + } + + fileprivate init(underlying: _SwiftSyntaxGenericTestSupport.TestFailureLocation) { + self.init( + fileID: underlying.fileID, + filePath: underlying.filePath, + line: underlying.line, + column: underlying.column + ) + } + + /// This type is intentionally different to `_SwiftSyntaxGenericTestSupport.TestFailureLocation` so we can + /// import `_SwiftSyntaxGenericTestSupport` privately and don't expose its internal types. + fileprivate var underlying: _SwiftSyntaxGenericTestSupport.TestFailureLocation { + _SwiftSyntaxGenericTestSupport.TestFailureLocation( + fileID: self.staticFileID, + filePath: self.staticFilePath, + line: self.unsignedLine, + column: self.unsignedColumn + ) + } +} + +/// Defines the details of a test failure, consisting of a message and the location at which the test failure should be +/// shown. +public struct TestFailureSpec { + public let message: String + public let location: TestFailureLocation + + public init(message: String, location: TestFailureLocation) { + self.message = message + self.location = location + } + + fileprivate init(underlying: _SwiftSyntaxGenericTestSupport.TestFailureSpec) { + self.init( + message: underlying.message, + location: TestFailureLocation(underlying: underlying.location) + ) + } +} + +// MARK: - Note + +/// Describes a diagnostic note that tests expect to be created by a macro expansion. +public struct NoteSpec { + /// The expected message of the note + public let message: String + + /// The line to which the note is expected to point + public let line: Int + + /// The column to which the note is expected to point + public let column: Int + + /// The file and line at which this ``NoteSpec`` was created, so that assertion failures can be reported at its location. + internal let failureLocation: TestFailureLocation + + /// Creates a new ``NoteSpec`` that describes a note tests are expecting to be generated by a macro expansion. + /// + /// - Parameters: + /// - message: The expected message of the note + /// - line: The line to which the note is expected to point + /// - column: The column to which the note is expected to point + /// - originatorFile: The file at which this ``NoteSpec`` was created, so that assertion failures can be reported at its location. + /// - originatorLine: The line at which this ``NoteSpec`` was created, so that assertion failures can be reported at its location. + public init( + message: String, + line: Int, + column: Int, + originatorFileID: StaticString = #fileID, + originatorFile: StaticString = #filePath, + originatorLine: UInt = #line, + originatorColumn: UInt = #column + ) { + self.message = message + self.line = line + self.column = column + self.failureLocation = TestFailureLocation( + fileID: originatorFileID, + filePath: originatorFile, + line: originatorLine, + column: originatorColumn + ) + } +} + +func assertNote( + _ note: Note, + in expansionContext: BasicMacroExpansionContext, + expected spec: NoteSpec, + failureHandler: (TestFailureSpec) -> Void +) { + assertStringsEqualWithDiff( + note.message, + spec.message, + "message of note does not match", + location: spec.failureLocation.underlying, + failureHandler: { failureHandler(TestFailureSpec(underlying: $0)) } + ) + let location = expansionContext.location(for: note.position, anchoredAt: note.node, fileName: "") + if location.line != spec.line { + failureHandler( + TestFailureSpec( + message: "line of note \(location.line) does not match expected line \(spec.line)", + location: spec.failureLocation + ) + ) + } + if location.column != spec.column { + failureHandler( + TestFailureSpec( + message: "column of note \(location.column) does not match expected column \(spec.column)", + location: spec.failureLocation + ) + ) + } +} + +// MARK: - Fix-It + +/// Describes a Fix-It that tests expect to be created by a macro expansion. +/// +/// Currently, it only compares the message of the Fix-It. In the future, it might +/// also compare the expected changes that should be performed by the Fix-It. +public struct FixItSpec { + /// The expected message of the Fix-It + public let message: String + + /// The file and line at which this ``FixItSpec`` was created, so that assertion failures can be reported at its location. + internal let failureLocation: TestFailureLocation + + /// Creates a new ``FixItSpec`` that describes a Fix-It tests are expecting to be generated by a macro expansion. + /// + /// - Parameters: + /// - message: The expected message of the note + /// - originatorFile: The file at which this ``NoteSpec`` was created, so that assertion failures can be reported at its location. + /// - originatorLine: The line at which this ``NoteSpec`` was created, so that assertion failures can be reported at its location. + public init( + message: String, + originatorFileID: StaticString = #fileID, + originatorFile: StaticString = #filePath, + originatorLine: UInt = #line, + originatorColumn: UInt = #column + ) { + self.message = message + self.failureLocation = TestFailureLocation( + fileID: originatorFileID, + filePath: originatorFile, + line: originatorLine, + column: originatorColumn + ) + } +} + +func assertFixIt( + _ fixIt: FixIt, + expected spec: FixItSpec, + failureHandler: (TestFailureSpec) -> Void +) { + assertStringsEqualWithDiff( + fixIt.message.message, + spec.message, + "message of Fix-It does not match", + location: spec.failureLocation.underlying, + failureHandler: { failureHandler(TestFailureSpec(underlying: $0)) } + ) +} + +// MARK: - Diagnostic + +/// Describes a diagnostic that tests expect to be created by a macro expansion. +public struct DiagnosticSpec { + /// If not `nil`, the ID, which the diagnostic is expected to have. + public let id: MessageID? + + /// The expected message of the diagnostic + public let message: String + + /// The line to which the diagnostic is expected to point + public let line: Int + + /// The column to which the diagnostic is expected to point + public let column: Int + + /// The expected severity of the diagnostic + public let severity: DiagnosticSeverity + + /// 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] + + /// The messages of the Fix-Its the diagnostic is expected to produce + public let fixIts: [FixItSpec] + + /// The file and line at which this ``NoteSpec`` was created, so that assertion failures can be reported at its location. + internal let failureLocation: TestFailureLocation + + /// 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. + /// - message: The expected message of the diagnostic + /// - 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 + /// - 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. + /// - originatorLine: The line at which this ``NoteSpec`` was created, so that assertion failures can be reported at its location. + public init( + id: MessageID? = nil, + message: String, + line: Int, + column: Int, + severity: DiagnosticSeverity = .error, + highlights: [String]? = nil, + notes: [NoteSpec] = [], + fixIts: [FixItSpec] = [], + originatorFileID: StaticString = #fileID, + originatorFile: StaticString = #filePath, + originatorLine: UInt = #line, + originatorColumn: UInt = #column + ) { + self.id = id + self.message = message + self.line = line + self.column = column + self.severity = severity + self.highlights = highlights + self.notes = notes + self.fixIts = fixIts + self.failureLocation = TestFailureLocation( + fileID: originatorFileID, + filePath: originatorFile, + line: originatorLine, + column: originatorColumn + ) + } +} + +extension DiagnosticSpec { + @available(*, deprecated, message: "Use highlights instead") + public var highlight: String? { + guard let highlights else { + return nil + } + return highlights.joined(separator: " ") + } + + // swift-format-ignore + @available(*, deprecated, message: "Use init(id:message:line:column:severity:highlights:notes:fixIts:originatorFile:originatorLine:) instead") + @_disfavoredOverload + public init( + id: MessageID? = nil, + message: String, + line: Int, + column: Int, + severity: DiagnosticSeverity = .error, + highlight: String? = nil, + notes: [NoteSpec] = [], + fixIts: [FixItSpec] = [], + originatorFile: StaticString = #filePath, + 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, + expected spec: DiagnosticSpec, + failureHandler: (TestFailureSpec) -> Void +) { + if let id = spec.id, diag.diagnosticID != id { + failureHandler( + TestFailureSpec( + message: "diagnostic ID \(diag.diagnosticID) does not match expected id \(id)", + location: spec.failureLocation + ) + ) + } + assertStringsEqualWithDiff( + diag.message, + spec.message, + "message does not match", + location: spec.failureLocation.underlying, + failureHandler: { failureHandler(TestFailureSpec(underlying: $0)) } + ) + let location = expansionContext.location(for: diag.position, anchoredAt: diag.node, fileName: "") + if location.line != spec.line { + failureHandler( + TestFailureSpec( + message: "line \(location.line) does not match expected line \(spec.line)", + location: spec.failureLocation + ) + ) + } + if location.column != spec.column { + failureHandler( + TestFailureSpec( + message: "column \(location.column) does not match expected column \(spec.column)", + location: spec.failureLocation + ) + ) + } + + if spec.severity != diag.diagMessage.severity { + failureHandler( + TestFailureSpec( + message: "severity \(diag.diagMessage.severity) does not match expected severity \(spec.severity)", + location: spec.failureLocation + ) + ) + } + + if let highlights = spec.highlights { + if diag.highlights.count != highlights.count { + failureHandler( + TestFailureSpec( + message: """ + Expected \(highlights.count) highlights but received \(diag.highlights.count): + \(diag.highlights.map(\.trimmedDescription).joined(separator: "\n")) + """, + location: spec.failureLocation + ) + ) + } else { + for (actual, expected) in zip(diag.highlights, highlights) { + assertStringsEqualWithDiff( + actual.trimmedDescription, + expected, + "highlight does not match", + location: spec.failureLocation.underlying, + failureHandler: { failureHandler(TestFailureSpec(underlying: $0)) } + ) + } + } + } + if diag.notes.count != spec.notes.count { + failureHandler( + TestFailureSpec( + message: """ + Expected \(spec.notes.count) notes but received \(diag.notes.count): + \(diag.notes.map(\.debugDescription).joined(separator: "\n")) + """, + location: spec.failureLocation + ) + ) + } else { + for (note, expectedNote) in zip(diag.notes, spec.notes) { + assertNote(note, in: expansionContext, expected: expectedNote, failureHandler: failureHandler) + } + } + if diag.fixIts.count != spec.fixIts.count { + failureHandler( + TestFailureSpec( + message: """ + Expected \(spec.fixIts.count) Fix-Its but received \(diag.fixIts.count): + \(diag.fixIts.map(\.message.message).joined(separator: "\n")) + """, + location: spec.failureLocation + ) + ) + } else { + for (fixIt, expectedFixIt) in zip(diag.fixIts, spec.fixIts) { + assertFixIt(fixIt, expected: expectedFixIt, failureHandler: failureHandler) + } + } +} + +/// Assert that expanding the given macros in the original source produces +/// the given expanded source code. +/// +/// - Parameters: +/// - originalSource: The original source code, which is expected to contain +/// macros in various places (e.g., `#stringify(x + y)`). +/// - expectedExpandedSource: The source code that we expect to see after +/// performing macro expansion on the original source. +/// - diagnostics: The diagnostics when expanding any macro +/// - macroSpecs: The macros that should be expanded, provided as a dictionary +/// mapping macro names (e.g., `"CodableMacro"`) to specification with macro type +/// (e.g., `CodableMacro.self`) and a list of conformances macro provides +/// (e.g., `["Decodable", "Encodable"]`). +/// - applyFixIts: If specified, filters the Fix-Its that are applied to generate `fixedSource` to only those whose message occurs in this array. If `nil`, all Fix-Its from the diagnostics are applied. +/// - fixedSource: If specified, asserts that the source code after applying Fix-Its matches this string. +/// - testModuleName: The name of the test module to use. +/// - testFileName: The name of the test file name to use. +/// - indentationWidth: The indentation width used in the expansion. +public func assertMacroExpansion( + _ originalSource: String, + expandedSource expectedExpandedSource: String, + diagnostics: [DiagnosticSpec] = [], + macroSpecs: [String: MacroSpec], + applyFixIts: [String]? = nil, + fixedSource expectedFixedSource: String? = nil, + testModuleName: String = "TestModule", + testFileName: String = "test.swift", + indentationWidth: Trivia = .spaces(4), + failureHandler: (TestFailureSpec) -> Void, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column +) { + let failureLocation = TestFailureLocation(fileID: fileID, filePath: filePath, line: line, column: column) + // Parse the original source file. + let origSourceFile = Parser.parse(source: originalSource) + + // Expand all macros in the source. + let context = BasicMacroExpansionContext( + sourceFiles: [origSourceFile: .init(moduleName: testModuleName, fullFilePath: testFileName)] + ) + + func contextGenerator(_ syntax: Syntax) -> BasicMacroExpansionContext { + return BasicMacroExpansionContext(sharingWith: context, lexicalContext: syntax.allMacroLexicalContexts()) + } + + let expandedSourceFile = origSourceFile.expand( + macroSpecs: macroSpecs, + contextGenerator: contextGenerator, + indentationWidth: indentationWidth + ) + let diags = ParseDiagnosticsGenerator.diagnostics(for: expandedSourceFile) + if !diags.isEmpty { + failureHandler( + TestFailureSpec( + message: """ + Expanded source should not contain any syntax errors, but contains: + \(DiagnosticsFormatter.annotatedSource(tree: expandedSourceFile, diags: diags)) + + Expanded syntax tree was: + \(expandedSourceFile.debugDescription) + """, + location: failureLocation + ) + ) + } + + assertStringsEqualWithDiff( + expandedSourceFile.description.drop(while: \.isNewline).droppingLast(while: \.isNewline), + expectedExpandedSource.drop(while: \.isNewline).droppingLast(while: \.isNewline), + "Macro expansion did not produce the expected expanded source", + additionalInfo: """ + Actual expanded source: + \(expandedSourceFile) + """, + location: failureLocation.underlying, + failureHandler: { failureHandler(TestFailureSpec(underlying: $0)) } + ) + + if context.diagnostics.count != diagnostics.count { + failureHandler( + TestFailureSpec( + message: """ + Expected \(diagnostics.count) diagnostics but received \(context.diagnostics.count): + \(context.diagnostics.map(\.debugDescription).joined(separator: "\n")) + """, + location: failureLocation + ) + ) + } else { + for (actualDiag, expectedDiag) in zip(context.diagnostics, diagnostics) { + assertDiagnostic(actualDiag, in: context, expected: expectedDiag, failureHandler: failureHandler) + } + } + + // Applying Fix-Its + if let expectedFixedSource = expectedFixedSource { + let messages = applyFixIts ?? context.diagnostics.compactMap { $0.fixIts.first?.message.message } + + let edits = + context.diagnostics + .flatMap(\.fixIts) + .filter { messages.contains($0.message.message) } + .flatMap { $0.changes } + .map { $0.edit(in: context) } + + let fixedTree = FixItApplier.apply(edits: edits, to: origSourceFile) + let fixedTreeDescription = fixedTree.description + assertStringsEqualWithDiff( + fixedTreeDescription.trimmingTrailingWhitespace(), + expectedFixedSource.trimmingTrailingWhitespace(), + location: failureLocation.underlying, + failureHandler: { failureHandler(TestFailureSpec(underlying: $0)) } + ) + } +} + +fileprivate extension FixIt.Change { + /// Returns the edit for this change, translating positions from detached nodes + /// to the corresponding locations in the original source file based on + /// `expansionContext`. + /// + /// - SeeAlso: `FixIt.Change.edit` + func edit(in expansionContext: BasicMacroExpansionContext) -> SourceEdit { + switch self { + case .replace(let oldNode, let newNode): + let start = expansionContext.position(of: oldNode.position, anchoredAt: oldNode) + let end = expansionContext.position(of: oldNode.endPosition, anchoredAt: oldNode) + return SourceEdit( + range: start.. AbsolutePosition { + let location = self.location(for: position, anchoredAt: Syntax(node), fileName: "") + return AbsolutePosition(utf8Offset: location.offset) + } +} diff --git a/Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift b/Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift index a107165c32b..50296b267ab 100644 --- a/Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift +++ b/Sources/SwiftSyntaxMacrosTestSupport/Assertions.swift @@ -11,339 +11,23 @@ //===----------------------------------------------------------------------===// #if swift(>=6.0) -import SwiftBasicFormat -public import SwiftDiagnostics -@_spi(FixItApplier) import SwiftIDEUtils -import SwiftParser -import SwiftParserDiagnostics public import SwiftSyntax public import SwiftSyntaxMacroExpansion public import SwiftSyntaxMacros -import _SwiftSyntaxTestSupport +@_spi(XCTestFailureLocation) public import SwiftSyntaxMacrosGenericTestSupport private import XCTest #else -import SwiftBasicFormat -import SwiftDiagnostics -@_spi(FixItApplier) import SwiftIDEUtils -import SwiftParser -import SwiftParserDiagnostics import SwiftSyntax import SwiftSyntaxMacroExpansion import SwiftSyntaxMacros -import _SwiftSyntaxTestSupport +@_spi(XCTestFailureLocation) import SwiftSyntaxMacrosGenericTestSupport import XCTest #endif -// MARK: - Note - -/// Describes a diagnostic note that tests expect to be created by a macro expansion. -public struct NoteSpec { - /// The expected message of the note - public let message: String - - /// The line to which the note is expected to point - public let line: Int - - /// The column to which the note is expected to point - public let column: Int - - /// The file and line at which this ``NoteSpec`` was created, so that assertion failures can be reported at its location. - internal let originatorFile: StaticString - internal let originatorLine: UInt - - /// Creates a new ``NoteSpec`` that describes a note tests are expecting to be generated by a macro expansion. - /// - /// - Parameters: - /// - message: The expected message of the note - /// - line: The line to which the note is expected to point - /// - column: The column to which the note is expected to point - /// - originatorFile: The file at which this ``NoteSpec`` was created, so that assertion failures can be reported at its location. - /// - originatorLine: The line at which this ``NoteSpec`` was created, so that assertion failures can be reported at its location. - public init( - message: String, - line: Int, - column: Int, - originatorFile: StaticString = #filePath, - originatorLine: UInt = #line - ) { - self.message = message - self.line = line - self.column = column - self.originatorFile = originatorFile - self.originatorLine = originatorLine - } -} - -func assertNote( - _ note: Note, - in expansionContext: BasicMacroExpansionContext, - expected spec: NoteSpec -) { - assertStringsEqualWithDiff( - note.message, - spec.message, - "message of note does not match", - file: spec.originatorFile, - line: spec.originatorLine - ) - let location = expansionContext.location(for: note.position, anchoredAt: note.node, fileName: "") - XCTAssertEqual( - location.line, - spec.line, - "line of note does not match", - file: spec.originatorFile, - line: spec.originatorLine - ) - XCTAssertEqual( - location.column, - spec.column, - "column of note does not match", - file: spec.originatorFile, - line: spec.originatorLine - ) -} - -// MARK: - Fix-It - -/// Describes a Fix-It that tests expect to be created by a macro expansion. -/// -/// Currently, it only compares the message of the Fix-It. In the future, it might -/// also compare the expected changes that should be performed by the Fix-It. -public struct FixItSpec { - /// The expected message of the Fix-It - public let message: String - - /// The file and line at which this ``NoteSpec`` was created, so that assertion failures can be reported at its location. - internal let originatorFile: StaticString - internal let originatorLine: UInt - - /// Creates a new ``FixItSpec`` that describes a Fix-It tests are expecting to be generated by a macro expansion. - /// - /// - Parameters: - /// - message: The expected message of the note - /// - originatorFile: The file at which this ``NoteSpec`` was created, so that assertion failures can be reported at its location. - /// - originatorLine: The line at which this ``NoteSpec`` was created, so that assertion failures can be reported at its location. - public init( - message: String, - originatorFile: StaticString = #filePath, - originatorLine: UInt = #line - ) { - self.message = message - self.originatorFile = originatorFile - self.originatorLine = originatorLine - } -} - -func assertFixIt( - _ fixIt: FixIt, - expected spec: FixItSpec -) { - assertStringsEqualWithDiff( - fixIt.message.message, - spec.message, - "message of Fix-It does not match", - file: spec.originatorFile, - line: spec.originatorLine - ) -} - -// MARK: - Diagnostic - -/// Describes a diagnostic that tests expect to be created by a macro expansion. -public struct DiagnosticSpec { - /// If not `nil`, the ID, which the diagnostic is expected to have. - public let id: MessageID? - - /// The expected message of the diagnostic - public let message: String - - /// The line to which the diagnostic is expected to point - public let line: Int - - /// The column to which the diagnostic is expected to point - public let column: Int - - /// The expected severity of the diagnostic - public let severity: DiagnosticSeverity - - /// 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] - - /// The messages of the Fix-Its the diagnostic is expected to produce - public let fixIts: [FixItSpec] - - /// The file and line at which this ``NoteSpec`` was created, so that assertion failures can be reported at its location. - internal let originatorFile: StaticString - internal let originatorLine: UInt - - /// 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. - /// - message: The expected message of the diagnostic - /// - 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 - /// - 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. - /// - originatorLine: The line at which this ``NoteSpec`` was created, so that assertion failures can be reported at its location. - public init( - id: MessageID? = nil, - message: String, - line: Int, - column: Int, - severity: DiagnosticSeverity = .error, - highlights: [String]? = nil, - notes: [NoteSpec] = [], - fixIts: [FixItSpec] = [], - originatorFile: StaticString = #filePath, - originatorLine: UInt = #line - ) { - self.id = id - self.message = message - self.line = line - self.column = column - self.severity = severity - self.highlights = highlights - self.notes = notes - self.fixIts = fixIts - self.originatorFile = originatorFile - self.originatorLine = originatorLine - } -} - -extension DiagnosticSpec { - @available(*, deprecated, message: "Use highlights instead") - public var highlight: String? { - guard let highlights else { - return nil - } - return highlights.joined(separator: " ") - } - - // swift-format-ignore - @available(*, deprecated, message: "Use init(id:message:line:column:severity:highlights:notes:fixIts:originatorFile:originatorLine:) instead") - @_disfavoredOverload - public init( - id: MessageID? = nil, - message: String, - line: Int, - column: Int, - severity: DiagnosticSeverity = .error, - highlight: String? = nil, - notes: [NoteSpec] = [], - fixIts: [FixItSpec] = [], - originatorFile: StaticString = #filePath, - 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, - expected spec: DiagnosticSpec -) { - if let id = spec.id { - XCTAssertEqual( - diag.diagnosticID, - id, - "diagnostic ID does not match", - file: spec.originatorFile, - line: spec.originatorLine - ) - } - assertStringsEqualWithDiff( - diag.message, - spec.message, - "message does not match", - file: spec.originatorFile, - line: spec.originatorLine - ) - let location = expansionContext.location(for: diag.position, anchoredAt: diag.node, fileName: "") - XCTAssertEqual(location.line, spec.line, "line does not match", file: spec.originatorFile, line: spec.originatorLine) - XCTAssertEqual( - location.column, - spec.column, - "column does not match", - file: spec.originatorFile, - line: spec.originatorLine - ) - - XCTAssertEqual( - spec.severity, - diag.diagMessage.severity, - "severity does not match", - file: spec.originatorFile, - line: spec.originatorLine - ) - - 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 - ) - } - } - } - if diag.notes.count != spec.notes.count { - XCTFail( - """ - Expected \(spec.notes.count) notes but received \(diag.notes.count): - \(diag.notes.map(\.debugDescription).joined(separator: "\n")) - """, - file: spec.originatorFile, - line: spec.originatorLine - ) - } else { - for (note, expectedNote) in zip(diag.notes, spec.notes) { - assertNote(note, in: expansionContext, expected: expectedNote) - } - } - if diag.fixIts.count != spec.fixIts.count { - XCTFail( - """ - Expected \(spec.fixIts.count) Fix-Its but received \(diag.fixIts.count): - \(diag.fixIts.map(\.message.message).joined(separator: "\n")) - """, - file: spec.originatorFile, - line: spec.originatorLine - ) - } else { - for (fixIt, expectedFixIt) in zip(diag.fixIts, spec.fixIts) { - assertFixIt(fixIt, expected: expectedFixIt) - } - } -} +// Re-export the spec types from `SwiftSyntaxMacrosGenericTestSupport`. +public typealias NoteSpec = SwiftSyntaxMacrosGenericTestSupport.NoteSpec +public typealias FixItSpec = SwiftSyntaxMacrosGenericTestSupport.FixItSpec +public typealias DiagnosticSpec = SwiftSyntaxMacrosGenericTestSupport.DiagnosticSpec /// Assert that expanding the given macros in the original source produces /// the given expanded source code. @@ -386,7 +70,9 @@ public func assertMacroExpansion( fixedSource: expectedFixedSource, testModuleName: testModuleName, testFileName: testFileName, - indentationWidth: indentationWidth + indentationWidth: indentationWidth, + file: file, + line: line ) } @@ -421,130 +107,22 @@ public func assertMacroExpansion( file: StaticString = #filePath, line: UInt = #line ) { - // Parse the original source file. - let origSourceFile = Parser.parse(source: originalSource) - - // Expand all macros in the source. - let context = BasicMacroExpansionContext( - sourceFiles: [origSourceFile: .init(moduleName: testModuleName, fullFilePath: testFileName)] - ) - - func contextGenerator(_ syntax: Syntax) -> BasicMacroExpansionContext { - return BasicMacroExpansionContext(sharingWith: context, lexicalContext: syntax.allMacroLexicalContexts()) - } - - let expandedSourceFile = origSourceFile.expand( + SwiftSyntaxMacrosGenericTestSupport.assertMacroExpansion( + originalSource, + expandedSource: expectedExpandedSource, + diagnostics: diagnostics, macroSpecs: macroSpecs, - contextGenerator: contextGenerator, - indentationWidth: indentationWidth - ) - let diags = ParseDiagnosticsGenerator.diagnostics(for: expandedSourceFile) - if !diags.isEmpty { - XCTFail( - """ - Expanded source should not contain any syntax errors, but contains: - \(DiagnosticsFormatter.annotatedSource(tree: expandedSourceFile, diags: diags)) - - Expanded syntax tree was: - \(expandedSourceFile.debugDescription) - """, - file: file, - line: line - ) - } - - assertStringsEqualWithDiff( - expandedSourceFile.description.trimmingCharacters(in: .newlines), - expectedExpandedSource.trimmingCharacters(in: .newlines), - "Macro expansion did not produce the expected expanded source", - additionalInfo: """ - Actual expanded source: - \(expandedSourceFile) - """, - file: file, - line: line + applyFixIts: applyFixIts, + fixedSource: expectedFixedSource, + testModuleName: testModuleName, + testFileName: testFileName, + indentationWidth: indentationWidth, + failureHandler: { + XCTFail($0.message, file: $0.location.staticFilePath, line: $0.location.unsignedLine) + }, + fileID: "", // Not used in the failure handler + filePath: file, + line: line, + column: 0 // Not used in the failure handler ) - - if context.diagnostics.count != diagnostics.count { - XCTFail( - """ - Expected \(diagnostics.count) diagnostics but received \(context.diagnostics.count): - \(context.diagnostics.map(\.debugDescription).joined(separator: "\n")) - """, - file: file, - line: line - ) - } else { - for (actualDiag, expectedDiag) in zip(context.diagnostics, diagnostics) { - assertDiagnostic(actualDiag, in: context, expected: expectedDiag) - } - } - - // Applying Fix-Its - if let expectedFixedSource = expectedFixedSource { - let messages = applyFixIts ?? context.diagnostics.compactMap { $0.fixIts.first?.message.message } - - let edits = - context.diagnostics - .flatMap(\.fixIts) - .filter { messages.contains($0.message.message) } - .flatMap { $0.changes } - .map { $0.edit(in: context) } - - let fixedTree = FixItApplier.apply(edits: edits, to: origSourceFile) - let fixedTreeDescription = fixedTree.description - assertStringsEqualWithDiff( - fixedTreeDescription.trimmingTrailingWhitespace(), - expectedFixedSource.trimmingTrailingWhitespace(), - file: file, - line: line - ) - } -} - -fileprivate extension FixIt.Change { - /// Returns the edit for this change, translating positions from detached nodes - /// to the corresponding locations in the original source file based on - /// `expansionContext`. - /// - /// - SeeAlso: `FixIt.Change.edit` - func edit(in expansionContext: BasicMacroExpansionContext) -> SourceEdit { - switch self { - case .replace(let oldNode, let newNode): - let start = expansionContext.position(of: oldNode.position, anchoredAt: oldNode) - let end = expansionContext.position(of: oldNode.endPosition, anchoredAt: oldNode) - return SourceEdit( - range: start.. AbsolutePosition { - let location = self.location(for: position, anchoredAt: Syntax(node), fileName: "") - return AbsolutePosition(utf8Offset: location.offset) - } } diff --git a/Sources/_SwiftSyntaxGenericTestSupport/AssertEqualWithDiff.swift b/Sources/_SwiftSyntaxGenericTestSupport/AssertEqualWithDiff.swift new file mode 100644 index 00000000000..d23a3031538 --- /dev/null +++ b/Sources/_SwiftSyntaxGenericTestSupport/AssertEqualWithDiff.swift @@ -0,0 +1,135 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// Defines the location at which the a test failure should be anchored. This is typically the location where the +/// assertion function is called. +public struct TestFailureLocation { + public let fileID: StaticString + public let filePath: StaticString + public let line: UInt + public let column: UInt + + public init( + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + self.fileID = fileID + self.filePath = filePath + self.line = line + self.column = column + } +} + +/// Defines the details of a test failure, consisting of a message and the location at which the test failure should be +/// shown. +public struct TestFailureSpec { + public let message: String + public let location: TestFailureLocation +} + +/// Asserts that the two strings are equal, providing Unix `diff`-style output if they are not. +/// +/// - Parameters: +/// - actual: The actual string. +/// - expected: The expected string. +/// - message: An optional description of the failure. +/// - additionalInfo: Additional information about the failed test case that will be printed after the diff +/// - file: The file in which failure occurred. Defaults to the file name of the test case in +/// which this function was called. +/// - line: The line number on which failure occurred. Defaults to the line number on which this +/// function was called. +public func assertStringsEqualWithDiff( + _ actual: String, + _ expected: String, + _ message: String = "", + additionalInfo: @autoclosure () -> String? = nil, + location: TestFailureLocation, + failureHandler: (TestFailureSpec) -> Void +) { + if actual == expected { + return + } + failStringsEqualWithDiff( + actual, + expected, + message, + additionalInfo: additionalInfo(), + location: location, + failureHandler: failureHandler + ) +} + +/// `XCTFail` with `diff`-style output. +public func failStringsEqualWithDiff( + _ actual: String, + _ expected: String, + _ message: String = "", + additionalInfo: @autoclosure () -> String? = nil, + location: TestFailureLocation, + failureHandler: (TestFailureSpec) -> Void +) { + let stringComparison: String + + // Use `CollectionDifference` on supported platforms to get `diff`-like line-based output. On + // older platforms, fall back to simple string comparison. + let actualLines = actual.split(separator: "\n") + let expectedLines = expected.split(separator: "\n") + + let difference = actualLines.difference(from: expectedLines) + + var result = "" + + var insertions = [Int: Substring]() + var removals = [Int: Substring]() + + for change in difference { + switch change { + case .insert(let offset, let element, _): + insertions[offset] = element + case .remove(let offset, let element, _): + removals[offset] = element + } + } + + var expectedLine = 0 + var actualLine = 0 + + while expectedLine < expectedLines.count || actualLine < actualLines.count { + if let removal = removals[expectedLine] { + result += "–\(removal)\n" + expectedLine += 1 + } else if let insertion = insertions[actualLine] { + result += "+\(insertion)\n" + actualLine += 1 + } else { + result += " \(expectedLines[expectedLine])\n" + expectedLine += 1 + actualLine += 1 + } + } + + stringComparison = result + + var fullMessage = """ + \(message.isEmpty ? "Actual output does not match the expected" : message) + \(stringComparison) + """ + if let additional = additionalInfo() { + fullMessage = """ + \(fullMessage) + \(additional) + """ + } + failureHandler(TestFailureSpec(message: fullMessage, location: location)) +} diff --git a/Sources/_SwiftSyntaxTestSupport/String+TrimmingTrailingWhitespace.swift b/Sources/_SwiftSyntaxGenericTestSupport/String+TrimmingTrailingWhitespace.swift similarity index 65% rename from Sources/_SwiftSyntaxTestSupport/String+TrimmingTrailingWhitespace.swift rename to Sources/_SwiftSyntaxGenericTestSupport/String+TrimmingTrailingWhitespace.swift index 4c70a3ac654..2134b5632c5 100644 --- a/Sources/_SwiftSyntaxTestSupport/String+TrimmingTrailingWhitespace.swift +++ b/Sources/_SwiftSyntaxGenericTestSupport/String+TrimmingTrailingWhitespace.swift @@ -11,11 +11,17 @@ //===----------------------------------------------------------------------===// extension String { - // This implementation is really slow; to use it outside a test it should be optimized. public func trimmingTrailingWhitespace() -> String { return self - .replacingOccurrences(of: "[ ]+\\n", with: "\n", options: .regularExpression) - .trimmingCharacters(in: [" "]) + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.droppingLast(while: \.isWhitespace) } + .joined(separator: "\n") + } +} + +public extension StringProtocol { + func droppingLast(while predicate: (Character) -> Bool) -> String { + return String(self.reversed().drop(while: predicate).reversed()) } } diff --git a/Sources/_SwiftSyntaxTestSupport/AssertEqualWithDiff.swift b/Sources/_SwiftSyntaxTestSupport/AssertEqualWithDiff.swift index 6fb8244b8b7..99f451a555a 100644 --- a/Sources/_SwiftSyntaxTestSupport/AssertEqualWithDiff.swift +++ b/Sources/_SwiftSyntaxTestSupport/AssertEqualWithDiff.swift @@ -13,9 +13,11 @@ #if swift(>=6) public import Foundation private import XCTest +private import _SwiftSyntaxGenericTestSupport #else import Foundation import XCTest +import _SwiftSyntaxGenericTestSupport #endif /// Asserts that the two strings are equal, providing Unix `diff`-style output if they are not. @@ -37,16 +39,21 @@ public func assertStringsEqualWithDiff( file: StaticString = #filePath, line: UInt = #line ) { - if actual == expected { - return - } - failStringsEqualWithDiff( + let location = TestFailureLocation( + fileID: "", // Not used in the failure handler + filePath: file, + line: line, + column: 0 // Not used in the failure handler + ) + return _SwiftSyntaxGenericTestSupport.assertStringsEqualWithDiff( actual, expected, message, additionalInfo: additionalInfo(), - file: file, - line: line + location: location, + failureHandler: { + XCTFail($0.message, file: $0.location.filePath, line: $0.location.line) + } ) } @@ -73,89 +80,19 @@ public func assertDataEqualWithDiff( return } - // NOTE: Converting to `Stirng` here looses invalid UTF8 sequence difference, - // but at least we can see something is different. - failStringsEqualWithDiff( - String(decoding: actual, as: UTF8.self), - String(decoding: expected, as: UTF8.self), + let actualString = String(decoding: actual, as: UTF8.self) + let expectedString = String(decoding: expected, as: UTF8.self) + + if actualString == expectedString { + XCTFail("Actual differs from expected data but underlying strings are equivalent", file: file, line: line) + } + + assertStringsEqualWithDiff( + actualString, + expectedString, message, additionalInfo: additionalInfo(), file: file, line: line ) } - -/// `XCTFail` with `diff`-style output. -public func failStringsEqualWithDiff( - _ actual: String, - _ expected: String, - _ message: String = "", - additionalInfo: @autoclosure () -> String? = nil, - file: StaticString = #filePath, - line: UInt = #line -) { - let stringComparison: String - - // Use `CollectionDifference` on supported platforms to get `diff`-like line-based output. On - // older platforms, fall back to simple string comparison. - if #available(macOS 10.15, *) { - let actualLines = actual.components(separatedBy: .newlines) - let expectedLines = expected.components(separatedBy: .newlines) - - let difference = actualLines.difference(from: expectedLines) - - var result = "" - - var insertions = [Int: String]() - var removals = [Int: String]() - - for change in difference { - switch change { - case .insert(let offset, let element, _): - insertions[offset] = element - case .remove(let offset, let element, _): - removals[offset] = element - } - } - - var expectedLine = 0 - var actualLine = 0 - - while expectedLine < expectedLines.count || actualLine < actualLines.count { - if let removal = removals[expectedLine] { - result += "–\(removal)\n" - expectedLine += 1 - } else if let insertion = insertions[actualLine] { - result += "+\(insertion)\n" - actualLine += 1 - } else { - result += " \(expectedLines[expectedLine])\n" - expectedLine += 1 - actualLine += 1 - } - } - - stringComparison = result - } else { - // Fall back to simple message on platforms that don't support CollectionDifference. - stringComparison = """ - Expected: - \(expected) - - Actual: - \(actual) - """ - } - - var fullMessage = """ - \(message.isEmpty ? "Actual output does not match the expected" : message) - \(stringComparison) - """ - if let additional = additionalInfo() { - fullMessage = """ - \(fullMessage) - \(additional) - """ - } - XCTFail(fullMessage, file: file, line: line) -} diff --git a/Tests/SwiftParserTest/Assertions.swift b/Tests/SwiftParserTest/Assertions.swift index 8514e75ed56..8cce493526a 100644 --- a/Tests/SwiftParserTest/Assertions.swift +++ b/Tests/SwiftParserTest/Assertions.swift @@ -99,7 +99,7 @@ private func assertTokens( } if actualLexeme.leadingTriviaText != expectedLexeme.leadingTrivia { - failStringsEqualWithDiff( + assertStringsEqualWithDiff( String(syntaxText: actualLexeme.leadingTriviaText), String(syntaxText: expectedLexeme.leadingTrivia), "Leading trivia does not match", @@ -109,7 +109,7 @@ private func assertTokens( } if actualLexeme.tokenText.debugDescription != expectedLexeme.tokenText.debugDescription { - failStringsEqualWithDiff( + assertStringsEqualWithDiff( actualLexeme.tokenText.debugDescription, expectedLexeme.tokenText.debugDescription, "Token text does not match", @@ -119,7 +119,7 @@ private func assertTokens( } if actualLexeme.trailingTriviaText != expectedLexeme.trailingTrivia { - failStringsEqualWithDiff( + assertStringsEqualWithDiff( String(syntaxText: actualLexeme.trailingTriviaText), String(syntaxText: expectedLexeme.trailingTrivia), "Trailing trivia does not match", @@ -402,7 +402,7 @@ func assertDiagnostic( line: spec.line ) } else if spec.fixIts != diag.fixIts.map(\.message.message) { - failStringsEqualWithDiff( + assertStringsEqualWithDiff( diag.fixIts.map(\.message.message).joined(separator: "\n"), spec.fixIts.joined(separator: "\n"), file: spec.file, diff --git a/Tests/SwiftParserTest/translated/RecoveryTests.swift b/Tests/SwiftParserTest/translated/RecoveryTests.swift index c158d4beba1..6a9c96a1452 100644 --- a/Tests/SwiftParserTest/translated/RecoveryTests.swift +++ b/Tests/SwiftParserTest/translated/RecoveryTests.swift @@ -2454,7 +2454,7 @@ final class RecoveryTests: ParserTestCase { DiagnosticSpec(message: "unexpected code '!=baz' in parameter clause"), ], fixedSource: """ - func foo1(bar: <#type#>!=baz) {} + func foo1(bar: <#type#>!=baz) {} """ ) }