Skip to content

Commit 7a16806

Browse files
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` - Changed `FixIt.message` to return a String instead of `FixItMessage` to match `Diagnostic` and `Note` - 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 ab0015f commit 7a16806

File tree

7 files changed

+495
-232
lines changed

7 files changed

+495
-232
lines changed

Package.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,14 @@ let package = Package(
162162

163163
.testTarget(
164164
name: "SwiftSyntaxMacrosTest",
165-
dependencies: ["_SwiftSyntaxTestSupport", "SwiftDiagnostics", "SwiftOperators", "SwiftParser", "SwiftSyntaxBuilder", "SwiftSyntaxMacros"]
165+
dependencies: ["_SwiftSyntaxTestSupport", "SwiftDiagnostics", "SwiftOperators", "SwiftParser", "SwiftSyntaxBuilder", "SwiftSyntaxMacros", "SwiftSyntaxMacrosTestSupport"]
166+
),
167+
168+
// MARK: SwiftSyntaxMacrosTestSupport
169+
170+
.target(
171+
name: "SwiftSyntaxMacrosTestSupport",
172+
dependencies: ["_SwiftSyntaxTestSupport", "SwiftDiagnostics", "SwiftParser", "SwiftSyntaxMacros"]
166173
),
167174

168175
// MARK: SwiftParser

Sources/SwiftCompilerPluginMessageHandling/Diagnostics.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ extension PluginMessage.Diagnostic {
8383

8484
self.fixIts = syntaxDiag.fixIts.compactMap {
8585
PluginMessage.Diagnostic.FixIt(
86-
message: $0.message.message,
86+
message: $0.message,
8787
changes: $0.changes.compactMap {
8888
let range: SourceManager.SourceRange?
8989
let text: String

Sources/SwiftDiagnostics/FixIt.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,19 @@ public struct FixIt {
3535
}
3636

3737
/// A description of what this Fix-It performs.
38-
public let message: FixItMessage
38+
public let fixItMessage: FixItMessage
3939

4040
/// The changes that need to be performed when the Fix-It is applied.
4141
public let changes: [Change]
4242

4343
public init(message: FixItMessage, changes: [Change]) {
4444
precondition(!changes.isEmpty, "A Fix-It must have at least one change associated with it")
45-
self.message = message
45+
self.fixItMessage = message
4646
self.changes = changes
4747
}
48+
49+
/// The message that should be displayed to the user.
50+
public var message: String {
51+
return fixItMessage.message
52+
}
4853
}

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

0 commit comments

Comments
 (0)