diff --git a/.swiftpm/configuration/Package.resolved b/.swiftpm/configuration/Package.resolved new file mode 100644 index 0000000..2c3597d --- /dev/null +++ b/.swiftpm/configuration/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "63d3b45dd249878a41c56274a748ca2c1c9c5230", + "version" : "1.17.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", + "version" : "510.0.2" + } + } + ], + "version" : 2 +} diff --git a/Package.resolved b/Package.resolved index 85b7712..f3edd8d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "8ddd519780452729c6634ad6bd0d2595938e9ea3", - "version" : "1.16.1" + "revision" : "f6c51fa7609b1057ca5420127440413c54971ff6", + "version" : "1.17.0" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax", "state" : { - "revision" : "303e5c5c36d6a558407d364878df131c3546fad8", - "version" : "510.0.2" + "revision" : "4c6cc0a3b9e8f14b3ae2307c5ccae4de6167ac2c", + "version" : "600.0.0-prerelease-2024-06-12" } } ], diff --git a/Package.swift b/Package.swift index 7b3e53b..b6942c9 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-syntax", "509.0.0"..<"601.0.0-prerelease"), - .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.16.0"), + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.1"), ], targets: [ .target( diff --git a/Sources/MacroTesting/AssertMacro.swift b/Sources/MacroTesting/AssertMacro.swift index c6fae47..f8cf2b3 100644 --- a/Sources/MacroTesting/AssertMacro.swift +++ b/Sources/MacroTesting/AssertMacro.swift @@ -1,4 +1,5 @@ import InlineSnapshotTesting +@_spi(Internals) import SnapshotTesting import SwiftDiagnostics import SwiftOperators import SwiftParser @@ -112,279 +113,285 @@ import XCTest public func assertMacro( _ macros: [String: Macro.Type]? = nil, indentationWidth: Trivia? = nil, - record isRecording: Bool? = nil, + record: SnapshotTestingConfiguration.Record? = nil, of originalSource: () throws -> String, diagnostics diagnosedSource: (() -> String)? = nil, fixes fixedSource: (() -> String)? = nil, expansion expandedSource: (() -> String)? = nil, - file: StaticString = #filePath, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, function: StaticString = #function, line: UInt = #line, column: UInt = #column ) { - let macros = macros ?? MacroTestingConfiguration.current.macros - guard !macros.isEmpty else { - XCTFail( - """ - No macros configured for this assertion. Pass a mapping to this function, e.g.: - - assertMacro(["stringify": StringifyMacro.self]) { … } - - Or wrap your assertion using 'withMacroTesting', e.g. in 'invokeTest': - - class StringifyMacroTests: XCTestCase { - override func invokeTest() { - withMacroTesting(macros: ["stringify": StringifyMacro.self]) { - super.invokeTest() + withSnapshotTesting(record: record ?? SnapshotTestingConfiguration.current?.record) { + let macros = macros ?? MacroTestingConfiguration.current.macros + guard let macros, !macros.isEmpty else { + recordIssue( + """ + No macros configured for this assertion. Pass a mapping to this function, e.g.: + + assertMacro(["stringify": StringifyMacro.self]) { … } + + Or wrap your assertion using 'withMacroTesting', e.g. in 'invokeTest': + + class StringifyMacroTests: XCTestCase { + override func invokeTest() { + withMacroTesting(macros: ["stringify": StringifyMacro.self]) { + super.invokeTest() + } } + … } - … - } - """, - file: file, - line: line - ) - return - } - - let wasRecording = SnapshotTesting.isRecording - SnapshotTesting.isRecording = isRecording ?? MacroTestingConfiguration.current.isRecording - defer { SnapshotTesting.isRecording = wasRecording } - - do { - var origSourceFile = Parser.parse(source: try originalSource()) - if let foldedSourceFile = try OperatorTable.standardOperators.foldAll(origSourceFile).as( - SourceFileSyntax.self - ) { - origSourceFile = foldedSourceFile - } - - let origDiagnostics = ParseDiagnosticsGenerator.diagnostics(for: origSourceFile) - let indentationWidth = - indentationWidth - ?? MacroTestingConfiguration.current.indentationWidth - ?? Trivia( - stringLiteral: String( - SourceLocationConverter(fileName: "-", tree: origSourceFile).sourceLines - .first(where: { $0.first?.isWhitespace == true && $0 != "\n" })? - .prefix(while: { $0.isWhitespace }) - ?? " " - ) - ) - - var context = BasicMacroExpansionContext( - sourceFiles: [ - origSourceFile: .init(moduleName: "TestModule", fullFilePath: "Test.swift") - ] - ) - #if canImport(SwiftSyntax600) - var expandedSourceFile = origSourceFile.expand( - macros: macros, - contextGenerator: { _ in context }, - indentationWidth: indentationWidth - ) - #else - var expandedSourceFile = origSourceFile.expand( - macros: macros, - in: context, - indentationWidth: indentationWidth - ) - #endif - - var offset = 0 - - func anchor(_ diag: Diagnostic) -> Diagnostic { - let location = context.location(for: diag.position, anchoredAt: diag.node, fileName: "") - return Diagnostic( - node: diag.node, - position: AbsolutePosition(utf8Offset: location.offset), - message: diag.diagMessage, - highlights: diag.highlights, - notes: diag.notes, - fixIts: diag.fixIts - ) - } - - var allDiagnostics: [Diagnostic] { origDiagnostics + context.diagnostics } - if !allDiagnostics.isEmpty || diagnosedSource != nil { - offset += 1 - - let converter = SourceLocationConverter(fileName: "-", tree: origSourceFile) - let lineCount = converter.location(for: origSourceFile.endPosition).line - let diagnostics = - DiagnosticsFormatter - .annotatedSource( - tree: origSourceFile, - diags: allDiagnostics.map(anchor), - context: context, - contextSize: lineCount - ) - .description - .replacingOccurrences(of: #"(^|\n) *\d* +│ "#, with: "$1", options: .regularExpression) - .trimmingCharacters(in: .newlines) - - assertInlineSnapshot( - of: diagnostics, - as: ._lines, - message: """ - Diagnostic output (\(newPrefix)) differed from expected output (\(oldPrefix)). \ - Difference: … - """, - syntaxDescriptor: InlineSnapshotSyntaxDescriptor( - deprecatedTrailingClosureLabels: ["matches"], - trailingClosureLabel: "diagnostics", - trailingClosureOffset: offset - ), - matches: diagnosedSource, - file: file, - function: function, - line: line, - column: column - ) - } else if diagnosedSource != nil { - offset += 1 - assertInlineSnapshot( - of: nil, - as: ._lines, - message: """ - Diagnostic output (\(newPrefix)) differed from expected output (\(oldPrefix)). \ - Difference: … - """, - syntaxDescriptor: InlineSnapshotSyntaxDescriptor( - deprecatedTrailingClosureLabels: ["matches"], - trailingClosureLabel: "diagnostics", - trailingClosureOffset: offset - ), - matches: diagnosedSource, - file: file, - function: function, + """, + fileID: fileID, + filePath: filePath, line: line, column: column ) + return } - - if !allDiagnostics.isEmpty && allDiagnostics.allSatisfy({ !$0.fixIts.isEmpty }) { - offset += 1 - - let edits = - context.diagnostics - .flatMap(\.fixIts) - .flatMap { $0.changes } - .map { $0.edit(in: context) } - - var fixedSourceFile = origSourceFile - fixedSourceFile = Parser.parse( - source: FixItApplier.apply( - edits: edits, to: origSourceFile - ) - .description - ) - if let foldedSourceFile = try OperatorTable.standardOperators.foldAll(fixedSourceFile).as( + do { + var origSourceFile = Parser.parse(source: try originalSource()) + if let foldedSourceFile = try OperatorTable.standardOperators.foldAll(origSourceFile).as( SourceFileSyntax.self ) { - fixedSourceFile = foldedSourceFile + origSourceFile = foldedSourceFile } - assertInlineSnapshot( - of: fixedSourceFile.description.trimmingCharacters(in: .newlines), - as: ._lines, - message: """ - Fixed output (\(newPrefix)) differed from expected output (\(oldPrefix)). \ - Difference: … - """, - syntaxDescriptor: InlineSnapshotSyntaxDescriptor( - trailingClosureLabel: "fixes", - trailingClosureOffset: offset - ), - matches: fixedSource, - file: file, - function: function, - line: line, - column: column - ) + let origDiagnostics = ParseDiagnosticsGenerator.diagnostics(for: origSourceFile) + let indentationWidth = + indentationWidth + ?? MacroTestingConfiguration.current.indentationWidth + ?? Trivia( + stringLiteral: String( + SourceLocationConverter(fileName: "-", tree: origSourceFile).sourceLines + .first(where: { $0.first?.isWhitespace == true && $0 != "\n" })? + .prefix(while: { $0.isWhitespace }) + ?? " " + ) + ) - context = BasicMacroExpansionContext( + var context = BasicMacroExpansionContext( sourceFiles: [ - fixedSourceFile: .init(moduleName: "TestModule", fullFilePath: "Test.swift") + origSourceFile: .init(moduleName: "TestModule", fullFilePath: "Test.swift") ] ) #if canImport(SwiftSyntax600) - expandedSourceFile = fixedSourceFile.expand( + var expandedSourceFile = origSourceFile.expand( macros: macros, contextGenerator: { _ in context }, indentationWidth: indentationWidth ) #else - expandedSourceFile = fixedSourceFile.expand( + var expandedSourceFile = origSourceFile.expand( macros: macros, in: context, indentationWidth: indentationWidth ) #endif - } else if fixedSource != nil { - offset += 1 - assertInlineSnapshot( - of: nil, - as: ._lines, - message: """ - Fixed output (\(newPrefix)) differed from expected output (\(oldPrefix)). \ - Difference: … - """, - syntaxDescriptor: InlineSnapshotSyntaxDescriptor( - trailingClosureLabel: "fixes", - trailingClosureOffset: offset - ), - matches: fixedSource, - file: file, - function: function, - line: line, - column: column - ) - } - if allDiagnostics.filter({ $0.diagMessage.severity == .error }).isEmpty { - offset += 1 - assertInlineSnapshot( - of: expandedSourceFile.description.trimmingCharacters(in: .newlines), - as: ._lines, - message: """ - Expanded output (\(newPrefix)) differed from expected output (\(oldPrefix)). \ - Difference: … - """, - syntaxDescriptor: InlineSnapshotSyntaxDescriptor( - deprecatedTrailingClosureLabels: ["matches"], - trailingClosureLabel: "expansion", - trailingClosureOffset: offset - ), - matches: expandedSource, - file: file, - function: function, - line: line, - column: column - ) - } else if expandedSource != nil { - offset += 1 - assertInlineSnapshot( - of: nil, - as: ._lines, - message: """ - Expanded output (\(newPrefix)) differed from expected output (\(oldPrefix)). \ - Difference: … - """, - syntaxDescriptor: InlineSnapshotSyntaxDescriptor( - deprecatedTrailingClosureLabels: ["matches"], - trailingClosureLabel: "expansion", - trailingClosureOffset: offset - ), - matches: expandedSource, - file: file, - function: function, + var offset = 0 + + func anchor(_ diag: Diagnostic) -> Diagnostic { + let location = context.location(for: diag.position, anchoredAt: diag.node, fileName: "") + return Diagnostic( + node: diag.node, + position: AbsolutePosition(utf8Offset: location.offset), + message: diag.diagMessage, + highlights: diag.highlights, + notes: diag.notes, + fixIts: diag.fixIts + ) + } + + var allDiagnostics: [Diagnostic] { origDiagnostics + context.diagnostics } + if !allDiagnostics.isEmpty || diagnosedSource != nil { + offset += 1 + + let converter = SourceLocationConverter(fileName: "-", tree: origSourceFile) + let lineCount = converter.location(for: origSourceFile.endPosition).line + let diagnostics = + DiagnosticsFormatter + .annotatedSource( + tree: origSourceFile, + diags: allDiagnostics.map(anchor), + context: context, + contextSize: lineCount + ) + .description + .replacingOccurrences(of: #"(^|\n) *\d* +│ "#, with: "$1", options: .regularExpression) + .trimmingCharacters(in: .newlines) + + assertInlineSnapshot( + of: diagnostics, + as: ._lines, + message: """ + Diagnostic output (\(newPrefix)) differed from expected output (\(oldPrefix)). \ + Difference: … + """, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + deprecatedTrailingClosureLabels: ["matches"], + trailingClosureLabel: "diagnostics", + trailingClosureOffset: offset + ), + matches: diagnosedSource, + file: filePath, + function: function, + line: line, + column: column + ) + } else if diagnosedSource != nil { + offset += 1 + assertInlineSnapshot( + of: nil, + as: ._lines, + message: """ + Diagnostic output (\(newPrefix)) differed from expected output (\(oldPrefix)). \ + Difference: … + """, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + deprecatedTrailingClosureLabels: ["matches"], + trailingClosureLabel: "diagnostics", + trailingClosureOffset: offset + ), + matches: diagnosedSource, + file: filePath, + function: function, + line: line, + column: column + ) + } + + if !allDiagnostics.isEmpty && allDiagnostics.allSatisfy({ !$0.fixIts.isEmpty }) { + offset += 1 + + let edits = + context.diagnostics + .flatMap(\.fixIts) + .flatMap { $0.changes } + .map { $0.edit(in: context) } + + var fixedSourceFile = origSourceFile + fixedSourceFile = Parser.parse( + source: FixItApplier.apply( + edits: edits, to: origSourceFile + ) + .description + ) + if let foldedSourceFile = try OperatorTable.standardOperators.foldAll(fixedSourceFile).as( + SourceFileSyntax.self + ) { + fixedSourceFile = foldedSourceFile + } + + assertInlineSnapshot( + of: fixedSourceFile.description.trimmingCharacters(in: .newlines), + as: ._lines, + message: """ + Fixed output (\(newPrefix)) differed from expected output (\(oldPrefix)). \ + Difference: … + """, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "fixes", + trailingClosureOffset: offset + ), + matches: fixedSource, + file: filePath, + function: function, + line: line, + column: column + ) + + context = BasicMacroExpansionContext( + sourceFiles: [ + fixedSourceFile: .init(moduleName: "TestModule", fullFilePath: "Test.swift") + ] + ) + #if canImport(SwiftSyntax600) + expandedSourceFile = fixedSourceFile.expand( + macros: macros, + contextGenerator: { _ in context }, + indentationWidth: indentationWidth + ) + #else + expandedSourceFile = fixedSourceFile.expand( + macros: macros, + in: context, + indentationWidth: indentationWidth + ) + #endif + } else if fixedSource != nil { + offset += 1 + assertInlineSnapshot( + of: nil, + as: ._lines, + message: """ + Fixed output (\(newPrefix)) differed from expected output (\(oldPrefix)). \ + Difference: … + """, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + trailingClosureLabel: "fixes", + trailingClosureOffset: offset + ), + matches: fixedSource, + file: filePath, + function: function, + line: line, + column: column + ) + } + + if allDiagnostics.filter({ $0.diagMessage.severity == .error }).isEmpty { + offset += 1 + assertInlineSnapshot( + of: expandedSourceFile.description.trimmingCharacters(in: .newlines), + as: ._lines, + message: """ + Expanded output (\(newPrefix)) differed from expected output (\(oldPrefix)). \ + Difference: … + """, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + deprecatedTrailingClosureLabels: ["matches"], + trailingClosureLabel: "expansion", + trailingClosureOffset: offset + ), + matches: expandedSource, + file: filePath, + function: function, + line: line, + column: column + ) + } else if expandedSource != nil { + offset += 1 + assertInlineSnapshot( + of: nil, + as: ._lines, + message: """ + Expanded output (\(newPrefix)) differed from expected output (\(oldPrefix)). \ + Difference: … + """, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor( + deprecatedTrailingClosureLabels: ["matches"], + trailingClosureLabel: "expansion", + trailingClosureOffset: offset + ), + matches: expandedSource, + file: filePath, + function: function, + line: line, + column: column + ) + } + } catch { + recordIssue( + "Threw error: \(error)", + fileID: fileID, + filePath: filePath, line: line, column: column ) } - } catch { - XCTFail("Threw error: \(error)", file: file, line: line) } } @@ -467,12 +474,13 @@ extension BasicMacroExpansionContext { public func assertMacro( _ macros: [Macro.Type], indentationWidth: Trivia? = nil, - record isRecording: Bool? = nil, + record: SnapshotTestingConfiguration.Record? = nil, of originalSource: () throws -> String, diagnostics diagnosedSource: (() -> String)? = nil, fixes fixedSource: (() -> String)? = nil, expansion expandedSource: (() -> String)? = nil, - file: StaticString = #filePath, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, function: StaticString = #function, line: UInt = #line, column: UInt = #column @@ -480,12 +488,13 @@ public func assertMacro( assertMacro( Dictionary(macros: macros), indentationWidth: indentationWidth, - record: isRecording, + record: record, of: originalSource, diagnostics: diagnosedSource, fixes: fixedSource, expansion: expandedSource, - file: file, + fileID: fileID, + file: filePath, function: function, line: line, column: column @@ -532,16 +541,17 @@ public func assertMacro( /// - operation: The operation to run with the configuration updated. public func withMacroTesting( indentationWidth: Trivia? = nil, - isRecording: Bool? = nil, + record: SnapshotTestingConfiguration.Record? = nil, macros: [String: Macro.Type]? = nil, operation: () async throws -> R -) async rethrows { +) async rethrows -> R { var configuration = MacroTestingConfiguration.current - if let indentationWidth = indentationWidth { configuration.indentationWidth = indentationWidth } - if let isRecording = isRecording { configuration.isRecording = isRecording } - if let macros = macros { configuration.macros = macros } - try await MacroTestingConfiguration.$current.withValue(configuration) { - try await operation() + if let indentationWidth { configuration.indentationWidth = indentationWidth } + if let macros { configuration.macros = macros } + return try await withSnapshotTesting(record: record) { + try await MacroTestingConfiguration.$current.withValue(configuration) { + try await operation() + } } } @@ -559,16 +569,17 @@ public func withMacroTesting( /// - operation: The operation to run with the configuration updated. public func withMacroTesting( indentationWidth: Trivia? = nil, - isRecording: Bool? = nil, + record: SnapshotTestingConfiguration.Record? = nil, macros: [String: Macro.Type]? = nil, operation: () throws -> R -) rethrows { +) rethrows -> R { var configuration = MacroTestingConfiguration.current - if let indentationWidth = indentationWidth { configuration.indentationWidth = indentationWidth } - if let isRecording = isRecording { configuration.isRecording = isRecording } - if let macros = macros { configuration.macros = macros } - try MacroTestingConfiguration.$current.withValue(configuration) { - try operation() + if let indentationWidth { configuration.indentationWidth = indentationWidth } + if let macros { configuration.macros = macros } + return try withSnapshotTesting(record: record) { + try MacroTestingConfiguration.$current.withValue(configuration) { + try operation() + } } } @@ -586,13 +597,13 @@ public func withMacroTesting( /// - operation: The operation to run with the configuration updated. public func withMacroTesting( indentationWidth: Trivia? = nil, - isRecording: Bool? = nil, + record: SnapshotTestingConfiguration.Record? = nil, macros: [Macro.Type], operation: () async throws -> R -) async rethrows { +) async rethrows -> R { try await withMacroTesting( indentationWidth: indentationWidth, - isRecording: isRecording, + record: record, macros: Dictionary(macros: macros), operation: operation ) @@ -612,13 +623,13 @@ public func withMacroTesting( /// - operation: The operation to run with the configuration updated. public func withMacroTesting( indentationWidth: Trivia? = nil, - isRecording: Bool? = nil, + record: SnapshotTestingConfiguration.Record? = nil, macros: [Macro.Type], operation: () throws -> R -) rethrows { +) rethrows -> R { try withMacroTesting( indentationWidth: indentationWidth, - isRecording: isRecording, + record: record, macros: Dictionary(macros: macros), operation: operation ) @@ -696,8 +707,7 @@ struct MacroTestingConfiguration { @TaskLocal static var current = Self() var indentationWidth: Trivia? = nil - var isRecording = false - var macros: [String: Macro.Type] = [:] + var macros: [String: Macro.Type]? } extension Dictionary where Key == String, Value == Macro.Type { diff --git a/Sources/MacroTesting/Internal/Deprecations.swift b/Sources/MacroTesting/Internal/Deprecations.swift index 8563aa1..7d86f9a 100644 --- a/Sources/MacroTesting/Internal/Deprecations.swift +++ b/Sources/MacroTesting/Internal/Deprecations.swift @@ -1,4 +1,5 @@ import InlineSnapshotTesting +@_spi(Internals) import SnapshotTesting import SwiftDiagnostics import SwiftOperators import SwiftParser @@ -8,6 +9,95 @@ import SwiftSyntaxMacroExpansion import SwiftSyntaxMacros import XCTest +// MARK: Deprecated after 0.4.2 + +@available(iOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@available(macOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@available(tvOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@available(visionOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@available(watchOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@_disfavoredOverload +public func withMacroTesting( + indentationWidth: Trivia? = nil, + isRecording: Bool? = nil, + macros: [String: Macro.Type]? = nil, + operation: () async throws -> R +) async rethrows { + var configuration = MacroTestingConfiguration.current + if let indentationWidth { configuration.indentationWidth = indentationWidth } + let record: SnapshotTestingConfiguration.Record? = isRecording.map { $0 ? .all : .missing } + if let macros { configuration.macros = macros } + _ = try await withSnapshotTesting(record: record) { + try await MacroTestingConfiguration.$current.withValue(configuration) { + try await operation() + } + } +} + +@available(iOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@available(macOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@available(tvOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@available(visionOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@available(watchOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@_disfavoredOverload +public func withMacroTesting( + indentationWidth: Trivia? = nil, + isRecording: Bool? = nil, + macros: [String: Macro.Type]? = nil, + operation: () throws -> R +) rethrows { + var configuration = MacroTestingConfiguration.current + if let indentationWidth { configuration.indentationWidth = indentationWidth } + let record: SnapshotTestingConfiguration.Record? = isRecording.map { $0 ? .all : .missing } + if let macros { configuration.macros = macros } + _ = try withSnapshotTesting(record: record) { + try MacroTestingConfiguration.$current.withValue(configuration) { + try operation() + } + } +} + +@available(iOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@available(macOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@available(tvOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@available(visionOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@available(watchOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@_disfavoredOverload +public func withMacroTesting( + indentationWidth: Trivia? = nil, + isRecording: Bool? = nil, + macros: [Macro.Type], + operation: () async throws -> R +) async rethrows { + try await withMacroTesting( + indentationWidth: indentationWidth, + isRecording: isRecording, + macros: Dictionary(macros: macros), + operation: operation + ) +} + + +@available(iOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@available(macOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@available(tvOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@available(visionOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@available(watchOS, deprecated, renamed: "withMacroTesting(indentationWidth:record:macros:operation:)") +@_disfavoredOverload +public func withMacroTesting( + indentationWidth: Trivia? = nil, + isRecording: Bool? = nil, + macros: [Macro.Type], + operation: () throws -> R +) rethrows { + try withMacroTesting( + indentationWidth: indentationWidth, + isRecording: isRecording, + macros: Dictionary(macros: macros), + operation: operation + ) +} + // MARK: Deprecated after 0.1.0 @available(*, deprecated, message: "Re-record this assertion") @@ -16,20 +106,27 @@ public func assertMacro( record isRecording: Bool? = nil, of originalSource: () throws -> String, matches expandedOrDiagnosedSource: () -> String, - file: StaticString = #filePath, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, function: StaticString = #function, line: UInt = #line, column: UInt = #column ) { - guard isRecording ?? MacroTestingConfiguration.current.isRecording else { - XCTFail("Re-record this assertion", file: file, line: line) + guard isRecording ?? (SnapshotTestingConfiguration.current?.record == .all) else { + recordIssue( + "Re-record this assertion", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) return } assertMacro( macros, record: true, of: originalSource, - file: file, + file: filePath, function: function, line: line, column: column @@ -42,7 +139,8 @@ public func assertMacro( record isRecording: Bool? = nil, of originalSource: () throws -> String, matches expandedOrDiagnosedSource: () -> String, - file: StaticString = #filePath, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, function: StaticString = #function, line: UInt = #line, column: UInt = #column @@ -52,7 +150,8 @@ public func assertMacro( record: isRecording, of: originalSource, matches: expandedOrDiagnosedSource, - file: file, + fileID: fileID, + file: filePath, function: function, line: line, column: column @@ -68,12 +167,19 @@ public func assertMacro( record isRecording: Bool? = nil, of originalSource: () throws -> String, matches expandedOrDiagnosedSource: () -> String, - file: StaticString = #filePath, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, function: StaticString = #function, line: UInt = #line, column: UInt = #column ) { - XCTFail("Delete 'matches' and re-record this assertion", file: file, line: line) + recordIssue( + "Delete 'matches' and re-record this assertion", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) } @available( @@ -85,7 +191,8 @@ public func assertMacro( record isRecording: Bool? = nil, of originalSource: () throws -> String, matches expandedOrDiagnosedSource: () -> String, - file: StaticString = #filePath, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, function: StaticString = #function, line: UInt = #line, column: UInt = #column @@ -96,7 +203,8 @@ public func assertMacro( record: isRecording, of: originalSource, matches: expandedOrDiagnosedSource, - file: file, + fileID: fileID, + file: filePath, function: function, line: line, column: column diff --git a/Sources/MacroTesting/Internal/RecordIssue.swift b/Sources/MacroTesting/Internal/RecordIssue.swift new file mode 100644 index 0000000..01c700d --- /dev/null +++ b/Sources/MacroTesting/Internal/RecordIssue.swift @@ -0,0 +1,32 @@ +import XCTest + +#if canImport(Testing) + import Testing +#endif + +@_spi(Internals) +public func recordIssue( + _ message: @autoclosure () -> String, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt +) { + #if canImport(Testing) + if Test.current != nil { + Issue.record( + Comment(rawValue: message()), + sourceLocation: SourceLocation( + fileID: fileID.description, + filePath: filePath.description, + line: Int(line), + column: Int(column) + ) + ) + } else { + XCTFail(message(), file: filePath, line: line) + } + #else + XCTFail(message(), file: filePath, line: line) + #endif +} diff --git a/Sources/MacroTesting/MacrosTestTrait.swift b/Sources/MacroTesting/MacrosTestTrait.swift new file mode 100644 index 0000000..99e860a --- /dev/null +++ b/Sources/MacroTesting/MacrosTestTrait.swift @@ -0,0 +1,51 @@ +#if canImport(Testing) + import SnapshotTesting + import SwiftSyntax +import SwiftSyntaxMacros + @_spi(Experimental) import Testing + + @_spi(Experimental) + extension Trait where Self == _MacrosTestTrait { + /// Configure snapshot testing in a suite or test. + /// + /// - Parameters: + /// - record: The record mode of the test. + /// - diffTool: The diff tool to use in failure messages. + public static func macros( + indentationWidth: Trivia? = nil, + record: SnapshotTestingConfiguration.Record? = nil, + macros: [String: Macro.Type]? = nil + ) -> Self { + _MacrosTestTrait( + configuration: MacroTestingConfiguration( + indentationWidth: indentationWidth, + macros: macros + ), + record: record + ) + } + } + + /// A type representing the configuration of snapshot testing. + @_spi(Experimental) + public struct _MacrosTestTrait: CustomExecutionTrait, SuiteTrait, TestTrait { + public let isRecursive = true + let configuration: MacroTestingConfiguration + let record: SnapshotTestingConfiguration.Record? + + public func execute( + _ function: @escaping () async throws -> Void, + for test: Test, + testCase: Test.Case? + ) async throws { + try await withMacroTesting( + indentationWidth: configuration.indentationWidth, + macros: configuration.macros + ) { + try await withSnapshotTesting(record: record) { + try await function() + } + } + } + } +#endif diff --git a/Tests/MacroTestingTests/AddAsyncTests.swift b/Tests/MacroTestingTests/AddAsyncTests.swift index e2d54f1..673709b 100644 --- a/Tests/MacroTestingTests/AddAsyncTests.swift +++ b/Tests/MacroTestingTests/AddAsyncTests.swift @@ -34,6 +34,7 @@ final class AddAsyncMacroTests: BaseTestCase { } } } + } """# } @@ -60,6 +61,7 @@ final class AddAsyncMacroTests: BaseTestCase { continuation.resume(returning: returnValue) } } + } """ } diff --git a/Tests/MacroTestingTests/AddCompletionHandlerTests.swift b/Tests/MacroTestingTests/AddCompletionHandlerTests.swift index f577d9c..9277064 100644 --- a/Tests/MacroTestingTests/AddCompletionHandlerTests.swift +++ b/Tests/MacroTestingTests/AddCompletionHandlerTests.swift @@ -26,6 +26,7 @@ final class AddCompletionHandlerTests: BaseTestCase { Task { completionHandler(await f(a: a, for: b, value)) } + } """ } @@ -93,6 +94,7 @@ final class AddCompletionHandlerTests: BaseTestCase { Task { completionHandler(await fetchData()) } + } } """ diff --git a/Tests/MacroTestingTests/BaseTestCase.swift b/Tests/MacroTestingTests/BaseTestCase.swift index cf4a54f..e079126 100644 --- a/Tests/MacroTestingTests/BaseTestCase.swift +++ b/Tests/MacroTestingTests/BaseTestCase.swift @@ -2,9 +2,11 @@ import MacroTesting import XCTest class BaseTestCase: XCTestCase { - // override func invokeTest() { - // withMacroTesting(isRecording: true) { - // super.invokeTest() - // } - // } + override func invokeTest() { + withMacroTesting( + record: .missing + ) { + super.invokeTest() + } + } } diff --git a/Tests/MacroTestingTests/OptionSetMacroTests.swift b/Tests/MacroTestingTests/OptionSetMacroTests.swift index 05cac84..ff36d98 100644 --- a/Tests/MacroTestingTests/OptionSetMacroTests.swift +++ b/Tests/MacroTestingTests/OptionSetMacroTests.swift @@ -50,16 +50,16 @@ final class OptionSetMacroTests: BaseTestCase { } static let nextDay: Self = - Self (rawValue: 1 << Options.nextDay.rawValue) + Self(rawValue: 1 << Options.nextDay.rawValue) static let secondDay: Self = - Self (rawValue: 1 << Options.secondDay.rawValue) + Self(rawValue: 1 << Options.secondDay.rawValue) static let priority: Self = - Self (rawValue: 1 << Options.priority.rawValue) + Self(rawValue: 1 << Options.priority.rawValue) static let standard: Self = - Self (rawValue: 1 << Options.standard.rawValue) + Self(rawValue: 1 << Options.standard.rawValue) } extension ShippingOptions: OptionSet { @@ -100,10 +100,10 @@ final class OptionSetMacroTests: BaseTestCase { } public static let nextDay: Self = - Self (rawValue: 1 << Options.nextDay.rawValue) + Self(rawValue: 1 << Options.nextDay.rawValue) public static let standard: Self = - Self (rawValue: 1 << Options.standard.rawValue) + Self(rawValue: 1 << Options.standard.rawValue) } """ } diff --git a/Tests/MacroTestingTests/SwiftTestingTests.swift b/Tests/MacroTestingTests/SwiftTestingTests.swift new file mode 100644 index 0000000..df857db --- /dev/null +++ b/Tests/MacroTestingTests/SwiftTestingTests.swift @@ -0,0 +1,55 @@ +#if canImport(Testing) + @_spi(Experimental) import MacroTesting + import Testing + + @Suite( + .macros( + //record: .failed, + macros: ["URL": URLMacro.self] + ) + ) + struct URLMacroSwiftTestingTests { + @Test + func expansionWithMalformedURLEmitsError() { + assertMacro { + """ + let invalid = #URL("https://not a url.com") + """ + } diagnostics: { + """ + let invalid = #URL("https://not a url.com") + ┬──────────────────────────── + ╰─ 🛑 malformed url: "https://not a url.com" + """ + } + } + + @Test + func expansionWithStringInterpolationEmitsError() { + assertMacro { + #""" + #URL("https://\(domain)/api/path") + """# + } diagnostics: { + #""" + #URL("https://\(domain)/api/path") + ┬───────────────────────────────── + ╰─ 🛑 #URL requires a static string literal + """# + } + } + + @Test + func expansionWithValidURL() { + assertMacro { + """ + let valid = #URL("https://swift.org/") + """ + } expansion: { + """ + let valid = URL(string: "https://swift.org/")! + """ + } + } + } +#endif