Skip to content

Commit bd9249e

Browse files
authored
Include fix-its in the diagnostic on a parameterized test when @Test is missing arguments: (#450)
This adds fix-its to the diagnostic emitted by the `@Test` macro when it has been applied to a function which includes parameters but does not specify `arguments: ...`. ### Motivation: When transforming a non-parameterized `@Test` function into a parameterized one, it's common to begin by adding one or more parameters to the function's signature. Immediately after doing that, the `@Test` macro emits an error diagnostic if `arguments:` is not passed to `@Test`. Since this is a common workflow and it may not always be obvious to new users where to add `arguments:`, it would be beneficial for this diagnostic to include a fix-it. ### Modifications: - Add a couple of styles of fix-it to this diagnostic, with variations for single- and multiple-parameter functions. - Enhance a utility on `EditorPlaceholderExprSyntax` to prefer typed placeholders (see code comment). ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated. Resolves rdar://128396593
1 parent da1d5ac commit bd9249e

File tree

5 files changed

+255
-27
lines changed

5 files changed

+255
-27
lines changed

Sources/TestingMacros/Support/Additions/EditorPlaceholderExprSyntaxAdditions.swift

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,44 @@
1111
import SwiftSyntax
1212

1313
extension EditorPlaceholderExprSyntax {
14-
/// Initialize an instance of this type with the given placeholder string.
14+
/// Initialize an instance of this type with the given placeholder string and
15+
/// optional type.
1516
///
1617
/// - Parameters:
1718
/// - placeholder: The placeholder string, not including surrounding angle
1819
/// brackets or pound characters.
19-
init(_ placeholder: String) {
20-
self.init(placeholder: .identifier("<# \(placeholder) #" + ">"))
20+
/// - type: The type which this placeholder have, if any. When non-`nil`,
21+
/// the expression will use typed placeholder syntax.
22+
init(_ placeholder: String, type: String? = nil) {
23+
let placeholderContent = if let type {
24+
// These use typed placeholder syntax, which allows the compiler to
25+
// type-check the expression successfully. The resulting code still does
26+
// not compile due to the placeholder, but it makes the diagnostic more
27+
// clear. See
28+
// https://developer.apple.com/documentation/swift-playgrounds/specifying-editable-regions-in-a-playground-page#Mark-Editable-Areas-with-Placeholder-Tokens
29+
if placeholder == type {
30+
// When the placeholder string is exactly the same as the type string,
31+
// use the shorter typed placeholder format.
32+
"T##\(placeholder)"
33+
} else {
34+
"T##\(placeholder)##\(type)"
35+
}
36+
} else {
37+
placeholder
38+
}
39+
40+
// Manually concatenate the string to avoid it being interpreted as a
41+
// placeholder when editing this file.
42+
self.init(placeholder: .identifier("<#\(placeholderContent)#" + ">"))
43+
}
44+
45+
/// Initialize an instance of this type with the given type, using that as the
46+
/// placeholder string.
47+
///
48+
/// - Parameters:
49+
/// - type: The type to use both as the placeholder text and as the
50+
/// expression's type.
51+
init(type: String) {
52+
self.init(type, type: type)
2153
}
2254
}

Sources/TestingMacros/Support/Additions/FunctionDeclSyntaxAdditions.swift

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,12 +136,8 @@ extension FunctionParameterSyntax {
136136
/// For example, for the parameter `y` of `func x(y: Int)`, the value of this
137137
/// property is an expression equivalent to `Int.self`.
138138
private var typeMetatypeExpression: some ExprSyntaxProtocol {
139-
// Discard any specifiers such as `inout` or `borrowing`, since we're only
140-
// trying to obtain the base type to reference it in an expression.
141-
let baseType = type.as(AttributedTypeSyntax.self)?.baseType ?? type
142-
143-
// Construct a member access expression, referencing the base type above.
144-
let baseTypeDeclReferenceExpr = DeclReferenceExprSyntax(baseName: .identifier(baseType.trimmedDescription))
139+
// Construct a member access expression, referencing the base type name.
140+
let baseTypeDeclReferenceExpr = DeclReferenceExprSyntax(baseName: .identifier(baseTypeName))
145141

146142
// Enclose the base type declaration reference in a 1-element tuple, e.g.
147143
// `(<baseType>)`. It will be used in a member access expression below, and
@@ -155,3 +151,13 @@ extension FunctionParameterSyntax {
155151
return MemberAccessExprSyntax(base: metatypeMemberAccessBase, name: .identifier("self"))
156152
}
157153
}
154+
155+
extension FunctionParameterSyntax {
156+
/// The base type name of this parameter.
157+
var baseTypeName: String {
158+
// Discard any specifiers such as `inout` or `borrowing`, since we're only
159+
// trying to obtain the base type to reference it in an expression.
160+
let baseType = type.as(AttributedTypeSyntax.self)?.baseType ?? type
161+
return baseType.trimmedDescription
162+
}
163+
}

Sources/TestingMacros/Support/DiagnosticMessage.swift

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -432,32 +432,109 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
432432
/// number of arguments when applied to the given function declaration.
433433
///
434434
/// - Parameters:
435-
/// - attribute: The `@Test` or `@Suite` attribute.
435+
/// - attribute: The `@Test` attribute.
436436
/// - functionDecl: The declaration in question.
437437
///
438438
/// - Returns: A diagnostic message.
439439
static func attributeArgumentCountIncorrect(_ attribute: AttributeSyntax, on functionDecl: FunctionDeclSyntax) -> Self {
440440
let expectedArgumentCount = functionDecl.signature.parameterClause.parameters.count
441-
switch expectedArgumentCount {
442-
case 0:
441+
if expectedArgumentCount == 0 {
443442
return Self(
444443
syntax: Syntax(functionDecl),
445-
message: "Attribute \(_macroName(attribute)) cannot specify arguments when used with '\(functionDecl.completeName)' because it does not take any",
444+
message: "Attribute \(_macroName(attribute)) cannot specify arguments when used with function '\(functionDecl.completeName)' because it does not take any",
446445
severity: .error
447446
)
448-
case 1:
447+
} else {
449448
return Self(
450449
syntax: Syntax(functionDecl),
451-
message: "Attribute \(_macroName(attribute)) must specify an argument when used with '\(functionDecl.completeName)'",
452-
severity: .error
450+
message: "Attribute \(_macroName(attribute)) must specify arguments when used with function '\(functionDecl.completeName)'",
451+
severity: .error,
452+
fixIts: _addArgumentsFixIts(for: attribute, given: functionDecl.signature.parameterClause.parameters)
453453
)
454-
default:
455-
return Self(
456-
syntax: Syntax(functionDecl),
457-
message: "Attribute \(_macroName(attribute)) must specify \(expectedArgumentCount) arguments when used with '\(functionDecl.completeName)'",
458-
severity: .error
454+
}
455+
}
456+
457+
/// Create fix-its for a diagnostic stating that the given attribute must
458+
/// specify arguments since it is applied to a function which has parameters.
459+
///
460+
/// - Parameters:
461+
/// - attribute: The `@Test` attribute.
462+
/// - parameters: The parameter list of the function `attribute` is applied
463+
/// to.
464+
///
465+
/// - Returns: An array of fix-its to include in a diagnostic.
466+
private static func _addArgumentsFixIts(for attribute: AttributeSyntax, given parameters: FunctionParameterListSyntax) -> [FixIt] {
467+
let baseArguments: LabeledExprListSyntax
468+
if let existingArguments = attribute.arguments {
469+
guard case var .argumentList(existingLabeledArguments) = existingArguments else {
470+
// If there are existing arguments but they are of an unexpected type,
471+
// don't attempt to provide any fix-its.
472+
return []
473+
}
474+
475+
// If the existing argument list is non-empty, ensure the last argument
476+
// has a trailing comma and space.
477+
if !existingLabeledArguments.isEmpty {
478+
let lastIndex = existingLabeledArguments.index(before: existingLabeledArguments.endIndex)
479+
existingLabeledArguments[lastIndex].trailingComma = .commaToken(trailingTrivia: .space)
480+
}
481+
482+
baseArguments = existingLabeledArguments
483+
} else {
484+
baseArguments = .init()
485+
}
486+
487+
var fixIts: [FixIt] = []
488+
func addFixIt(_ message: String, appendingArguments arguments: some Collection<LabeledExprSyntax>) {
489+
var newAttribute = attribute
490+
newAttribute.leftParen = .leftParenToken()
491+
newAttribute.arguments = .argumentList(baseArguments + arguments)
492+
let trailingTrivia = newAttribute.rightParen?.trailingTrivia
493+
?? newAttribute.attributeName.as(IdentifierTypeSyntax.self)?.name.trailingTrivia
494+
?? .space
495+
newAttribute.rightParen = .rightParenToken(trailingTrivia: trailingTrivia)
496+
newAttribute.attributeName = newAttribute.attributeName.trimmed
497+
498+
fixIts.append(FixIt(
499+
message: MacroExpansionFixItMessage(message),
500+
changes: [.replace(oldNode: Syntax(attribute), newNode: Syntax(newAttribute))]
501+
))
502+
}
503+
504+
// Fix-It to add 'arguments:' with one collection. If the function has 2 or
505+
// more parameters, the elements of the placeholder collection are of tuple
506+
// type.
507+
do {
508+
let argumentsCollectionType = if parameters.count == 1, let parameter = parameters.first {
509+
"[\(parameter.baseTypeName)]"
510+
} else {
511+
"[(\(parameters.map(\.baseTypeName).joined(separator: ", ")))]"
512+
}
513+
514+
addFixIt(
515+
"Add 'arguments:' with one collection",
516+
appendingArguments: [LabeledExprSyntax(label: "arguments", expression: EditorPlaceholderExprSyntax(type: argumentsCollectionType))]
459517
)
460518
}
519+
520+
// Fix-It to add 'arguments:' with all combinations of <N> collections,
521+
// where <N> is the count of the function's parameters. Only offered for
522+
// functions with 2 parameters.
523+
if parameters.count == 2 {
524+
let additionalArguments = parameters.indices.map { index in
525+
let label = index == parameters.startIndex ? "arguments" : nil
526+
let argumentsCollectionType = "[\(parameters[index].baseTypeName)]"
527+
return LabeledExprSyntax(
528+
label: label.map { .identifier($0) },
529+
colon: label == nil ? nil : .colonToken(trailingTrivia: .space),
530+
expression: EditorPlaceholderExprSyntax(type: argumentsCollectionType),
531+
trailingComma: parameters.index(after: index) < parameters.endIndex ? .commaToken(trailingTrivia: .space) : nil
532+
)
533+
}
534+
addFixIt("Add 'arguments:' with all combinations of \(parameters.count) collections", appendingArguments: additionalArguments)
535+
}
536+
537+
return fixIts
461538
}
462539

463540
/// Create a diagnostic message stating that `@Test` or `@Suite` is
@@ -565,7 +642,7 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
565642
fixIts: [
566643
FixIt(
567644
message: MacroExpansionFixItMessage(#"Replace "\#(urlString)" with URL"#),
568-
changes: [.replace(oldNode: Syntax(urlExpr), newNode: Syntax(EditorPlaceholderExprSyntax("url")))]
645+
changes: [.replace(oldNode: Syntax(urlExpr), newNode: Syntax(EditorPlaceholderExprSyntax("url", type: "String")))]
569646
),
570647
FixIt(
571648
message: MacroExpansionFixItMessage("Remove trait '\(traitName.trimmed)'"),

Tests/TestingMacrosTests/TestDeclarationMacroTests.swift

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,8 @@ struct TestDeclarationMacroTests {
9898
"Attribute 'Test' cannot be applied to a function with a parameter marked '_const'",
9999
100100
// Argument count mismatches.
101-
"@Test func f(i: Int) {}":
102-
"Attribute 'Test' must specify an argument when used with 'f(i:)'",
103-
"@Test func f(i: Int, j: Int) {}":
104-
"Attribute 'Test' must specify 2 arguments when used with 'f(i:j:)'",
105101
"@Test(arguments: []) func f() {}":
106-
"Attribute 'Test' cannot specify arguments when used with 'f()' because it does not take any",
102+
"Attribute 'Test' cannot specify arguments when used with function 'f()' because it does not take any",
107103
108104
// Invalid lexical contexts
109105
"struct S { func f() { @Test func g() {} } }":
@@ -158,6 +154,95 @@ struct TestDeclarationMacroTests {
158154
}
159155
}
160156

157+
static var errorsWithFixIts: [String: (message: String, fixIts: [ExpectedFixIt])] {
158+
[
159+
// 'Test' attribute must specify arguments to parameterized test functions.
160+
"@Test func f(i: Int) {}":
161+
(
162+
message: "Attribute 'Test' must specify arguments when used with function 'f(i:)'",
163+
fixIts: [
164+
ExpectedFixIt(
165+
message: "Add 'arguments:' with one collection",
166+
changes: [.replace(oldSourceCode: "@Test ", newSourceCode: "@Test(arguments: \(EditorPlaceholderExprSyntax(type: "[Int]"))) ")]
167+
),
168+
]
169+
),
170+
"@Test func f(i: Int, j: String) {}":
171+
(
172+
message: "Attribute 'Test' must specify arguments when used with function 'f(i:j:)'",
173+
fixIts: [
174+
ExpectedFixIt(
175+
message: "Add 'arguments:' with one collection",
176+
changes: [.replace(oldSourceCode: "@Test ", newSourceCode: "@Test(arguments: \(EditorPlaceholderExprSyntax(type: "[(Int, String)]"))) ")]
177+
),
178+
ExpectedFixIt(
179+
message: "Add 'arguments:' with all combinations of 2 collections",
180+
changes: [.replace(oldSourceCode: "@Test ", newSourceCode: "@Test(arguments: \(EditorPlaceholderExprSyntax(type: "[Int]")), \(EditorPlaceholderExprSyntax(type: "[String]"))) ")]
181+
),
182+
]
183+
),
184+
"@Test func f(i: Int, j: String, k: Double) {}":
185+
(
186+
message: "Attribute 'Test' must specify arguments when used with function 'f(i:j:k:)'",
187+
fixIts: [
188+
ExpectedFixIt(
189+
message: "Add 'arguments:' with one collection",
190+
changes: [.replace(oldSourceCode: "@Test ", newSourceCode: "@Test(arguments: \(EditorPlaceholderExprSyntax(type: "[(Int, String, Double)]"))) ")]
191+
),
192+
]
193+
),
194+
#"@Test("Some display name") func f(i: Int) {}"#:
195+
(
196+
message: "Attribute 'Test' must specify arguments when used with function 'f(i:)'",
197+
fixIts: [
198+
ExpectedFixIt(
199+
message: "Add 'arguments:' with one collection",
200+
changes: [.replace(oldSourceCode: #"@Test("Some display name") "#, newSourceCode: #"@Test("Some display name", arguments: \#(EditorPlaceholderExprSyntax(type: "[Int]"))) "#)]
201+
),
202+
]
203+
),
204+
#"@Test /*comment*/ func f(i: Int) {}"#:
205+
(
206+
message: "Attribute 'Test' must specify arguments when used with function 'f(i:)'",
207+
fixIts: [
208+
ExpectedFixIt(
209+
message: "Add 'arguments:' with one collection",
210+
changes: [.replace(oldSourceCode: #"@Test /*comment*/ "#, newSourceCode: #"@Test(arguments: \#(EditorPlaceholderExprSyntax(type: "[Int]"))) /*comment*/ "#)]
211+
),
212+
]
213+
),
214+
]
215+
}
216+
217+
@Test("Error diagnostics which include fix-its emitted on API misuse", arguments: errorsWithFixIts)
218+
func apiMisuseErrorsIncludingFixIts(input: String, expectedDiagnostic: (message: String, fixIts: [ExpectedFixIt])) throws {
219+
let (_, diagnostics) = try parse(input)
220+
221+
#expect(diagnostics.count == 1)
222+
let diagnostic = try #require(diagnostics.first)
223+
#expect(diagnostic.diagMessage.severity == .error)
224+
#expect(diagnostic.message == expectedDiagnostic.message)
225+
226+
try #require(diagnostic.fixIts.count == expectedDiagnostic.fixIts.count)
227+
for (fixIt, expectedFixIt) in zip(diagnostic.fixIts, expectedDiagnostic.fixIts) {
228+
#expect(fixIt.message.message == expectedFixIt.message)
229+
230+
try #require(fixIt.changes.count == expectedFixIt.changes.count)
231+
for (change, expectedChange) in zip(fixIt.changes, expectedFixIt.changes) {
232+
switch (change, expectedChange) {
233+
case let (.replace(oldNode, newNode), .replace(expectedOldSourceCode, expectedNewSourceCode)):
234+
let oldSourceCode = String(describing: oldNode.formatted())
235+
#expect(oldSourceCode == expectedOldSourceCode)
236+
237+
let newSourceCode = String(describing: newNode.formatted())
238+
#expect(newSourceCode == expectedNewSourceCode)
239+
default:
240+
Issue.record("Change \(change) differs from expected change \(expectedChange)")
241+
}
242+
}
243+
}
244+
}
245+
161246
@Test("Warning diagnostics emitted on API misuse",
162247
arguments: [
163248
// return types
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
/// A type representing a fix-it which is expected to be included in a
12+
/// diagnostic from a macro.
13+
struct ExpectedFixIt {
14+
/// A description of what this expected fix-it performs.
15+
var message: String
16+
17+
/// An enumeration describing a change to be performed by a fix-it.
18+
///
19+
/// - Note: Not all changes in the real `FixIt` type are currently supported
20+
/// and included in this list.
21+
enum Change {
22+
/// Replace `oldSourceCode` by `newSourceCode`.
23+
case replace(oldSourceCode: String, newSourceCode: String)
24+
}
25+
26+
/// The changes that would be performed when this expected fix-it is applied.
27+
var changes: [Change] = []
28+
}

0 commit comments

Comments
 (0)