Skip to content

Commit 8d1b64d

Browse files
ahoppenAlex Hoppen
authored and
Alex Hoppen
committed
Add a public function that can be used to test macro expansions
Testing macros should be as easy as possible and we should thus make the `assertMacroExpansion` function public. While making the function public, I made the following changes: - Changed `diagnosticMessages` to expect `DiagnsoticSpec` similar to the parser tests. The only difference is that they take line and columns instead of location markers because the location markers have a slighgly high learning curve IMO. Slightly unrelated, I also made the following changes: - Made a few minor improvements to `SwiftParserTest/Assertions.swift` - Fixed a bug in MacroApplication where the wrong syntax node was passed as the `node` parameter` to `addDiagnostics`, leading to a highlight that was bigger than expected.
1 parent 560df35 commit 8d1b64d

File tree

5 files changed

+480
-220
lines changed

5 files changed

+480
-220
lines changed

Package.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ let package = Package(
5656
.library(name: "SwiftSyntax", type: .static, targets: ["SwiftSyntax"]),
5757
.library(name: "SwiftSyntaxBuilder", type: .static, targets: ["SwiftSyntaxBuilder"]),
5858
.library(name: "SwiftSyntaxMacros", type: .static, targets: ["SwiftSyntaxMacros"]),
59+
.library(name: "SwiftSyntaxMacrosTestSupport", type: .static, targets: ["SwiftSyntaxMacrosTestSupport"]),
5960
],
6061
targets: [
6162
// MARK: - Internal helper targets
@@ -173,7 +174,14 @@ let package = Package(
173174

174175
.testTarget(
175176
name: "SwiftSyntaxMacrosTest",
176-
dependencies: ["_SwiftSyntaxTestSupport", "SwiftDiagnostics", "SwiftOperators", "SwiftParser", "SwiftSyntaxBuilder", "SwiftSyntaxMacros"]
177+
dependencies: ["_SwiftSyntaxTestSupport", "SwiftDiagnostics", "SwiftOperators", "SwiftParser", "SwiftSyntaxBuilder", "SwiftSyntaxMacros", "SwiftSyntaxMacrosTestSupport"]
178+
),
179+
180+
// MARK: SwiftSyntaxMacrosTestSupport
181+
182+
.target(
183+
name: "SwiftSyntaxMacrosTestSupport",
184+
dependencies: ["_SwiftSyntaxTestSupport", "SwiftDiagnostics", "SwiftParser", "SwiftSyntaxMacros"]
177185
),
178186

179187
// MARK: SwiftParser

Sources/SwiftSyntaxMacros/MacroSystem.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ class MacroApplication<Context: MacroExpansionContext>: SyntaxRewriter {
200200
}
201201
)
202202
} catch {
203-
context.addDiagnostics(from: error, node: node)
203+
context.addDiagnostics(from: error, node: declExpansion)
204204
}
205205

206206
continue
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import _SwiftSyntaxTestSupport
14+
import SwiftBasicFormat
15+
import SwiftDiagnostics
16+
import SwiftParser
17+
import SwiftSyntax
18+
import SwiftSyntaxMacros
19+
import XCTest
20+
21+
private extension String {
22+
// This implementation is really slow; to use it outside a test it should be optimized.
23+
func trimmingTrailingWhitespace() -> String {
24+
return self.replacingOccurrences(of: "[ ]+\\n", with: "\n", options: .regularExpression)
25+
}
26+
}
27+
28+
// MARK: - Note
29+
30+
/// Describes a diagnostic note that tests expect to be created by a macro expansion.
31+
public struct NoteSpec {
32+
/// The expected message of the note
33+
public let message: String
34+
35+
/// The line to which the note is expected to point
36+
public let line: Int
37+
38+
/// The column to which the note is expected to point
39+
public let column: Int
40+
41+
/// The file and line at which this `NoteSpec` was created, so that assertion failures can be reported at its location.
42+
internal let originatorFile: StaticString
43+
internal let originatorLine: UInt
44+
45+
/// Creates a new `NoteSpec` that describes a note tests are expecting to be generated by a macro expansion.
46+
///
47+
/// - Parameters:
48+
/// - message: The expected message of the note
49+
/// - line: The line to which the note is expected to point
50+
/// - column: The column to which the note is expected to point
51+
/// - originatorFile: The file at which this `NoteSpec` was created, so that assertion failures can be reported at its location.
52+
/// - originatorLine: The line at which this `NoteSpec` was created, so that assertion failures can be reported at its location.
53+
public init(
54+
message: String,
55+
line: Int,
56+
column: Int,
57+
originatorFile: StaticString = #file,
58+
originatorLine: UInt = #line
59+
) {
60+
self.message = message
61+
self.line = line
62+
self.column = column
63+
self.originatorFile = originatorFile
64+
self.originatorLine = originatorLine
65+
}
66+
}
67+
68+
func assertNote<T: SyntaxProtocol>(
69+
_ note: Note,
70+
in tree: T,
71+
expected spec: NoteSpec
72+
) {
73+
assertStringsEqualWithDiff(note.message, spec.message, "message of note does not match", file: spec.originatorFile, line: spec.originatorLine)
74+
let location = note.location(converter: SourceLocationConverter(file: "", source: tree.description))
75+
XCTAssertEqual(location.line, spec.line, "line of note does not match", file: spec.originatorFile, line: spec.originatorLine)
76+
XCTAssertEqual(location.column, spec.column, "column of note does not match", file: spec.originatorFile, line: spec.originatorLine)
77+
}
78+
79+
// MARK: - Fix-It
80+
81+
/// Describes a Fix-It that tests expect to be created by a macro expansion.
82+
///
83+
/// Currently, it only compares the message of the Fix-It. In the future, it might
84+
/// also compare the expected changes that should be performed by the Fix-It.
85+
public struct FixItSpec {
86+
/// The expected message of the Fix-It
87+
public let message: String
88+
89+
/// The file and line at which this `NoteSpec` was created, so that assertion failures can be reported at its location.
90+
internal let originatorFile: StaticString
91+
internal let originatorLine: UInt
92+
93+
/// Creates a new `FixItSpec` that describes a Fix-It tests are expecting to be generated by a macro expansion.
94+
///
95+
/// - Parameters:
96+
/// - message: The expected message of the note
97+
/// - originatorFile: The file at which this `NoteSpec` was created, so that assertion failures can be reported at its location.
98+
/// - originatorLine: The line at which this `NoteSpec` was created, so that assertion failures can be reported at its location.
99+
public init(
100+
message: String,
101+
originatorFile: StaticString = #file,
102+
originatorLine: UInt = #line
103+
) {
104+
self.message = message
105+
self.originatorFile = originatorFile
106+
self.originatorLine = originatorLine
107+
}
108+
}
109+
110+
func assertFixIt(
111+
_ fixIt: FixIt,
112+
expected spec: FixItSpec
113+
) {
114+
assertStringsEqualWithDiff(fixIt.message.message, spec.message, "message of Fix-It does not match", file: spec.originatorFile, line: spec.originatorLine)
115+
}
116+
117+
// MARK: - Diagnostic
118+
119+
/// Describes a diagnostic that tests expect to be created by a macro expansion.
120+
public struct DiagnosticSpec {
121+
/// If not `nil`, the ID, which the diagnostic is expected to have.
122+
public let id: MessageID?
123+
124+
/// The expected message of the diagnostic
125+
public let message: String
126+
127+
/// The line to which the diagnostic is expected to point
128+
public let line: Int
129+
130+
/// The column to which the diagnostic is expected to point
131+
public let column: Int
132+
133+
/// The expected severity of the diagnostic
134+
public let severity: DiagnosticSeverity
135+
136+
/// If not `nil`, the text the diagnostic is expected to highlight
137+
public let highlight: String?
138+
139+
/// The notes that are expected to be attached to the diagnostic
140+
public let notes: [NoteSpec]
141+
142+
/// The messages of the Fix-Its the diagnostic is expected to produce
143+
public let fixIts: [FixItSpec]
144+
145+
/// The file and line at which this `NoteSpec` was created, so that assertion failures can be reported at its location.
146+
internal let originatorFile: StaticString
147+
internal let originatorLine: UInt
148+
149+
/// Creates a new `DiagnosticSpec` that describes a diagnsotic tests are expecting to be generated by a macro expansion.
150+
///
151+
/// - Parameters:
152+
/// - id: If not `nil`, the ID, which the diagnostic is expected to have.
153+
/// - message: The expected message of the diagnostic
154+
/// - line: The line to which the diagnostic is expected to point
155+
/// - column: The column to which the diagnostic is expected to point
156+
/// - severity: The expected severity of the diagnostic
157+
/// - highlight: If not `nil`, the text the diagnostic is expected to highlight
158+
/// - notes: The notes that are expected to be attached to the diagnostic
159+
/// - fixIts: The messages of the Fix-Its the diagnostic is expected to produce
160+
/// - originatorFile: The file at which this `NoteSpec` was created, so that assertion failures can be reported at its location.
161+
/// - originatorLine: The line at which this `NoteSpec` was created, so that assertion failures can be reported at its location.
162+
public init(
163+
id: MessageID? = nil,
164+
message: String,
165+
line: Int,
166+
column: Int,
167+
severity: DiagnosticSeverity = .error,
168+
highlight: String? = nil,
169+
notes: [NoteSpec] = [],
170+
fixIts: [FixItSpec] = [],
171+
originatorFile: StaticString = #file,
172+
originatorLine: UInt = #line
173+
) {
174+
self.id = id
175+
self.message = message
176+
self.line = line
177+
self.column = column
178+
self.severity = severity
179+
self.highlight = highlight
180+
self.notes = notes
181+
self.fixIts = fixIts
182+
self.originatorFile = originatorFile
183+
self.originatorLine = originatorLine
184+
}
185+
}
186+
187+
func assertDiagnostic<T: SyntaxProtocol>(
188+
_ diag: Diagnostic,
189+
in tree: T,
190+
expected spec: DiagnosticSpec
191+
) {
192+
if let id = spec.id {
193+
XCTAssertEqual(diag.diagnosticID, id, "diagnostic ID does not match", file: spec.originatorFile, line: spec.originatorLine)
194+
}
195+
assertStringsEqualWithDiff(diag.message, spec.message, "message does not match", file: spec.originatorFile, line: spec.originatorLine)
196+
let location = diag.location(converter: SourceLocationConverter(file: "", source: tree.description))
197+
XCTAssertEqual(location.line, spec.line, "line does not match", file: spec.originatorFile, line: spec.originatorLine)
198+
XCTAssertEqual(location.column, spec.column, "column does not match", file: spec.originatorFile, line: spec.originatorLine)
199+
200+
XCTAssertEqual(spec.severity, diag.diagMessage.severity, "severity does not match", file: spec.originatorFile, line: spec.originatorLine)
201+
202+
if let highlight = spec.highlight {
203+
var highlightedCode = ""
204+
highlightedCode.append(diag.highlights.first?.with(\.leadingTrivia, []).description ?? "")
205+
for highlight in diag.highlights.dropFirst().dropLast() {
206+
highlightedCode.append(highlight.description)
207+
}
208+
if diag.highlights.count > 1 {
209+
highlightedCode.append(diag.highlights.last?.with(\.trailingTrivia, []).description ?? "")
210+
}
211+
212+
assertStringsEqualWithDiff(
213+
highlightedCode.trimmingTrailingWhitespace(),
214+
highlight.trimmingTrailingWhitespace(),
215+
"highlight does not match",
216+
file: spec.originatorFile,
217+
line: spec.originatorLine
218+
)
219+
}
220+
if diag.notes.count != spec.notes.count {
221+
XCTFail(
222+
"""
223+
Expected \(spec.notes.count) notes but received \(diag.notes.count):
224+
\(diag.notes.map(\.debugDescription).joined(separator: "\n"))
225+
""",
226+
file: spec.originatorFile,
227+
line: spec.originatorLine
228+
)
229+
} else {
230+
for (note, expectedNote) in zip(diag.notes, spec.notes) {
231+
assertNote(note, in: tree, expected: expectedNote)
232+
}
233+
}
234+
if diag.fixIts.count != spec.fixIts.count {
235+
XCTFail(
236+
"""
237+
Expected \(spec.fixIts.count) Fix-Its but received \(diag.fixIts.count):
238+
\(diag.fixIts.map(\.message.message).joined(separator: "\n"))
239+
""",
240+
file: spec.originatorFile,
241+
line: spec.originatorLine
242+
)
243+
} else {
244+
for (fixIt, expectedFixIt) in zip(diag.fixIts, spec.fixIts) {
245+
assertFixIt(fixIt, expected: expectedFixIt)
246+
}
247+
}
248+
}
249+
250+
/// Assert that expanding the given macros in the original source produces
251+
/// the given expanded source code.
252+
///
253+
/// - Parameters:
254+
/// - originalSource: The original source code, which is expected to contain
255+
/// macros in various places (e.g., `#stringify(x + y)`).
256+
/// - expandedSource: The source code that we expect to see after performing
257+
/// macro expansion on the original source.
258+
/// - diagnostics:
259+
/// - macros: The macros that should be expanded, provided as a dictionary
260+
/// mapping macro names (e.g., `"stringify"`) to implementation types
261+
/// (e.g., `StringifyMacro.self`).
262+
/// - testModuleName: The name of the test module to use.
263+
/// - testFileName: The name of the test file name to use.
264+
public func assertMacroExpansion(
265+
_ originalSource: String,
266+
expandedSource: String,
267+
diagnostics: [DiagnosticSpec] = [],
268+
macros: [String: Macro.Type],
269+
testModuleName: String = "TestModule",
270+
testFileName: String = "test.swift",
271+
file: StaticString = #file,
272+
line: UInt = #line
273+
) {
274+
// Parse the original source file.
275+
let origSourceFile = Parser.parse(source: originalSource)
276+
277+
// Expand all macros in the source.
278+
let context = BasicMacroExpansionContext(
279+
sourceFiles: [origSourceFile: .init(moduleName: testModuleName, fullFilePath: testFileName)]
280+
)
281+
let expandedSourceFile = origSourceFile.expand(macros: macros, in: context).formatted()
282+
283+
assertStringsEqualWithDiff(
284+
expandedSourceFile.description.trimmingTrailingWhitespace(),
285+
expandedSource.trimmingTrailingWhitespace(),
286+
file: file,
287+
line: line
288+
)
289+
290+
if context.diagnostics.count != diagnostics.count {
291+
XCTFail(
292+
"""
293+
Expected \(diagnostics.count) diagnostics but received \(context.diagnostics.count):
294+
\(context.diagnostics.map(\.debugDescription).joined(separator: "\n"))
295+
""",
296+
file: file,
297+
line: line
298+
)
299+
} else {
300+
for (actualDiag, expectedDiag) in zip(context.diagnostics, diagnostics) {
301+
assertDiagnostic(actualDiag, in: origSourceFile, expected: expectedDiag)
302+
}
303+
}
304+
}

0 commit comments

Comments
 (0)