Skip to content

Commit 6994a77

Browse files
authored
Warn about effectful expectations. (#302)
This PR adds diagnostics (emitted at macro expansion time) when an effectful expression is passed to `#expect()` or `#require()`. For example: ```swift #expect(try foo()) // ⚠️ Expression 'try foo()' will not be expanded on failure; // move the throwing part out of the call to '#expect(_:_:)' ``` (A fix-it here is not possible for the same reasons we need to diagnose in the first place, explained momentarily.) Expressions containing `try` or `await` are affected; the diagnostic can be suppressed by adding an explicit cast to the expression's type (`as Bool` or `as T?`.) ### Why can't we break down these expressions? The `try` and `await` keywords in Swift are allowed to be used anywhere in an expression or a containing expression and cover _all_ throwing/asynchronous subexpressions. For example, the following is valid even though only `foo()` strictly needs the `await` keyword: ```swift func foo() async -> Int { ... } #expect(await quux(1, 2, 3, foo() + bar()) > 10) ``` Because swift-testing can only see the syntax tree (that is, the characters the developer typed into a Swift source file) and not the types or effects of expressions, when presented with the `#expect()` expression above, it has no way to know that the only part of the expression that needs to be awaited is `foo()`. Expression expansion works by breaking down an expression into known subexpression patterns. For example, `x.y(z: 123)` represents a member function call and useful subexpressions include `x`, and the argument `z: 123`: ```swift __checkFunctionCall( x, // the base expression calling: { $0.y(z: $1) }, // a closure that invokes the .y(z:) member function 123 // the argument, labelled 'z', to the member function ) ``` These subexpressions can then be presented as their source code _and_ runtime values if an expectation fails, allowing developers to quickly see that e.g. `x` was misspecified or `123` should have been `456`. But if some subexpression is effectful, there's no way for swift-testing to break down the whole expression into syntactically and contextually correct subexpressions because there's no way to know where the effects need to be reapplied. Given the similar expression `await x.y(z: 123)`, where does `await` need to go when calling `__checkFunctionCall()`? ```swift await __checkFunctionCall( x, // should this be `await x`? calling: { $0.y(z: $1) }, // `{ await $0.y(z: $1) }` perhaps? 123 // well, at least this is an integer literal... ) ``` If the `await` is placed in the wrong location, an error occurs after macro expansion. If swift-testing is paranoid and adds `await` to _every_ subexpression (literals aside), warnings occur. Diagnostics occur no matter what we do unless _every_ subexpression just so happened to be effectful. ### What about that `__requiringAwait` trick used during expansion of `@Test`? (See [here](https://github.com/apple/swift-testing/blob/932cb01aa2da55fb410b8fede294efa8d5545f62/Sources/Testing/Test%2BMacro.swift#L464) and [here](https://github.com/apple/swift-testing/blob/932cb01aa2da55fb410b8fede294efa8d5545f62/Sources/TestingMacros/TestDeclarationMacro.swift#L306).) This is a tempting approach, but it comes with a serious caveat: it would introduce additional suspension points to code that might only need a single one. The result would be code that behaves differently in a call to `#expect()` than when invoked directly, which would be a serious defect in swift-testing. #### What about just doing that for `try`? I had _almost_ gotten this working, but ran into the problem that macros behave differently from functions in a way that would make an expansion syntactically incorrect: ```swift #expect(try foo()) // 🛑 Call can throw but is not marked with 'try' ``` In effect, adding the expansion here would require that the developer always write `try #expect(try foo())` (i.e. `try` twice) which is inconsistent with how the developer would write a function call that takes the result of `foo()` in a non-obvious way. ### Okay, so where does that leave us? This PR adds the diagnostics I mentioned above, remember? I also took the time to adjust the other diagnostics we emit to more closely match Swift/LLVM [house style](https://github.com/apple/swift/blob/main/docs/Diagnostics.md). So the diff is more extensive than was necessary _just_ for the new diagnostics, but the result is a more consistent developer experience. Resolves rdar://124976452. ### 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.
1 parent 5a243f7 commit 6994a77

12 files changed

+312
-144
lines changed

Sources/TestingMacros/Support/ConditionArgumentParsing.swift

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,64 @@ func removeParentheses(from expr: ExprSyntax) -> ExprSyntax? {
128128
return nil
129129
}
130130

131+
/// A class that walks a syntax tree looking for `try` and `await` expressions.
132+
///
133+
/// - Bug: This class does not use `lexicalContext` to check for the presence of
134+
/// `try` or `await` _outside_ the current macro expansion.
135+
private final class _EffectFinder: SyntaxVisitor {
136+
/// The effectful expressions discovered so far.
137+
var effectfulExprs = [ExprSyntax]()
138+
139+
/// Common implementation for `visit(_: TryExprSyntax)` and
140+
/// `visit(_: AwaitExprSyntax)`.
141+
///
142+
/// - Parameters:
143+
/// - node: The `try` or `await` expression.
144+
/// - expression: The `.expression` property of `node`.
145+
///
146+
/// - Returns: Whether or not to recurse into `node`.
147+
private func _visitEffectful(_ node: some ExprSyntaxProtocol, expression: ExprSyntax) -> SyntaxVisitorContinueKind {
148+
if let parentNode = node.parent, parentNode.is(TryExprSyntax.self) {
149+
// Suppress this expression as its immediate parent is also an effectful
150+
// expression (e.g. it's a `try await` expression overall.) The diagnostic
151+
// reported for the parent expression should include both as needed.
152+
return .visitChildren
153+
} else if expression.is(AsExprSyntax.self) {
154+
// Do not walk into explicit `as T` casts. This provides an escape hatch
155+
// for expressions that should not diagnose.
156+
return .skipChildren
157+
} else if let awaitExpr = expression.as(AwaitExprSyntax.self), awaitExpr.expression.is(AsExprSyntax.self) {
158+
// As above but for `try await _ as T`.
159+
return .skipChildren
160+
}
161+
effectfulExprs.append(ExprSyntax(node))
162+
return .visitChildren
163+
}
164+
165+
override func visit(_ node: TryExprSyntax) -> SyntaxVisitorContinueKind {
166+
_visitEffectful(node, expression: node.expression)
167+
}
168+
169+
override func visit(_ node: AwaitExprSyntax) -> SyntaxVisitorContinueKind {
170+
_visitEffectful(node, expression: node.expression)
171+
}
172+
173+
override func visit(_ node: AsExprSyntax) -> SyntaxVisitorContinueKind {
174+
// Do not walk into explicit `as T` casts. This provides an escape hatch for
175+
// expressions that should not diagnose.
176+
return .skipChildren
177+
}
178+
179+
override func visit(_ node: ClosureExprSyntax) -> SyntaxVisitorContinueKind {
180+
// Do not walk into closures. Although they are not meant to be an escape
181+
// hatch like `as` casts, it is very difficult (often impossible) to reason
182+
// about effectful expressions inside the scope of a closure. If the closure
183+
// is invoked locally, its caller will also need to say `try`/`await` and we
184+
// can still diagnose those outer expressions.
185+
return .skipChildren
186+
}
187+
}
188+
131189
// MARK: -
132190

133191
/// Parse a condition argument from a binary operation expression.
@@ -496,6 +554,17 @@ private func _parseCondition(from expr: ExprSyntax, for macro: some Freestanding
496554
///
497555
/// - Returns: An instance of ``Condition`` describing `expr`.
498556
func parseCondition(from expr: ExprSyntax, for macro: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext) -> Condition {
557+
// Handle `await` first. If present in the expression or a subexpression,
558+
// diagnose and don't expand further.
559+
let effectFinder = _EffectFinder(viewMode: .sourceAccurate)
560+
effectFinder.walk(expr)
561+
guard effectFinder.effectfulExprs.isEmpty else {
562+
for effectfulExpr in effectFinder.effectfulExprs {
563+
context.diagnose(.effectfulExpressionNotParsed(effectfulExpr, in: macro))
564+
}
565+
return Condition(expression: expr)
566+
}
567+
499568
let result = _parseCondition(from: expr, for: macro, in: context)
500569
if result.arguments.count == 1, let onlyArgument = result.arguments.first {
501570
_diagnoseTrivialBooleanValue(from: onlyArgument.expression, for: macro, in: context)

Sources/TestingMacros/Support/DiagnosticMessage.swift

Lines changed: 75 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
3232
///
3333
/// - Returns: A diagnostic message.
3434
static func condition(_ condition: ExprSyntax, isAlways value: Bool, in macro: some FreestandingMacroExpansionSyntax) -> Self {
35-
Self(
35+
let action = value ? "pass" : "fail"
36+
return Self(
3637
syntax: Syntax(condition),
37-
message: "#\(macro.macroName.textWithoutBackticks)(_:_:) will always \(value ? "pass" : "fail") here; use Bool(\(condition)) to silence this warning",
38+
message: "\(_macroName(macro)) will always \(action) here; use 'Bool(\(condition))' to silence this warning",
3839
severity: value ? .note : .warning
3940
)
4041
}
@@ -50,11 +51,59 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
5051
static func asExclamationMarkIsEvaluatedEarly(_ expr: AsExprSyntax, in macro: some FreestandingMacroExpansionSyntax) -> Self {
5152
return Self(
5253
syntax: Syntax(expr.asKeyword),
53-
message: "The expression \(expr.trimmed) will be evaluated before #\(macro.macroName.textWithoutBackticks)(_:_:) is invoked; use as? instead of as! to silence this warning",
54+
message: "Expression '\(expr.trimmed)' will be evaluated before \(_macroName(macro)) is invoked; use 'as?' instead of 'as!' to silence this warning",
55+
severity: .warning
56+
)
57+
}
58+
59+
/// Create a diagnostic message stating that an effectful (`try` or `await`)
60+
/// expression cannot be parsed and should be broken apart.
61+
///
62+
/// - Parameters:
63+
/// - expr: The expression being diagnosed.
64+
/// - macro: The macro expression.
65+
///
66+
/// - Returns: A diagnostic message.
67+
static func effectfulExpressionNotParsed(_ expr: ExprSyntax, in macro: some FreestandingMacroExpansionSyntax) -> Self {
68+
let effectful = if let tryExpr = expr.as(TryExprSyntax.self) {
69+
if tryExpr.expression.is(AwaitExprSyntax.self) {
70+
"throwing/asynchronous"
71+
} else {
72+
"throwing"
73+
}
74+
} else {
75+
"asynchronous"
76+
}
77+
return Self(
78+
syntax: Syntax(expr),
79+
message: "Expression '\(expr.trimmed)' will not be expanded on failure; move the \(effectful) part out of the call to \(_macroName(macro))",
5480
severity: .warning
5581
)
5682
}
5783

84+
/// Get the human-readable name of the given freestanding macro.
85+
///
86+
/// - Parameters:
87+
/// - macro: The freestanding macro node to name.
88+
///
89+
/// - Returns: The name of the macro as understood by a developer, such as
90+
/// `"'#expect(_:_:)'"`. Include single quotes.
91+
private static func _macroName(_ macro: some FreestandingMacroExpansionSyntax) -> String {
92+
"'#\(macro.macroName.textWithoutBackticks)(_:_:)'"
93+
}
94+
95+
/// Get the human-readable name of the given attached macro.
96+
///
97+
/// - Parameters:
98+
/// - attribute: The attached macro node to name.
99+
///
100+
/// - Returns: The name of the macro as understood by a developer, such as
101+
/// `"'@Test'"`. Include single quotes.
102+
private static func _macroName(_ attribute: AttributeSyntax) -> String {
103+
// SEE: https://github.com/apple/swift/blob/main/docs/Diagnostics.md?plain=1#L44
104+
"'\(attribute.attributeNameText)'"
105+
}
106+
58107
/// Get a string corresponding to the specified syntax node (for instance,
59108
/// `"function"` for a function declaration.)
60109
///
@@ -117,7 +166,7 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
117166
precondition(!attributes.isEmpty)
118167
return Self(
119168
syntax: Syntax(attributes.last!),
120-
message: "The @\(attributes.last!.attributeNameText) attribute cannot be applied to \(_kindString(for: decl, includeA: true)) more than once.",
169+
message: "Attribute \(_macroName(attributes.last!)) cannot be applied to \(_kindString(for: decl, includeA: true)) more than once",
121170
severity: .error
122171
)
123172
}
@@ -134,7 +183,7 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
134183
static func genericDeclarationNotSupported(_ decl: some SyntaxProtocol, whenUsing attribute: AttributeSyntax, becauseOf genericClause: some SyntaxProtocol) -> Self {
135184
Self(
136185
syntax: Syntax(genericClause),
137-
message: "The @\(attribute.attributeNameText) attribute cannot be applied to a generic \(_kindString(for: decl)).",
186+
message: "Attribute \(_macroName(attribute)) cannot be applied to a generic \(_kindString(for: decl))",
138187
severity: .error
139188
)
140189
}
@@ -155,7 +204,7 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
155204
static func availabilityAttributeNotSupported(_ availabilityAttribute: AttributeSyntax, on decl: some SyntaxProtocol, whenUsing attribute: AttributeSyntax) -> Self {
156205
Self(
157206
syntax: Syntax(availabilityAttribute),
158-
message: "The @\(attribute.attributeNameText) attribute cannot be applied to this \(_kindString(for: decl)) because it has been marked \(availabilityAttribute.trimmed).",
207+
message: "Attribute \(_macroName(attribute)) cannot be applied to this \(_kindString(for: decl)) because it has been marked '\(availabilityAttribute.trimmed)'",
159208
severity: .error
160209
)
161210
}
@@ -171,7 +220,7 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
171220
static func attributeNotSupported(_ attribute: AttributeSyntax, on decl: some SyntaxProtocol) -> Self {
172221
Self(
173222
syntax: Syntax(decl),
174-
message: "The @\(attribute.attributeNameText) attribute cannot be applied to \(_kindString(for: decl, includeA: true)).",
223+
message: "Attribute \(_macroName(attribute)) cannot be applied to \(_kindString(for: decl, includeA: true))",
175224
severity: .error
176225
)
177226
}
@@ -187,8 +236,14 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
187236
static func attributeHasNoEffect(_ attribute: AttributeSyntax, on decl: ExtensionDeclSyntax) -> Self {
188237
Self(
189238
syntax: Syntax(decl),
190-
message: "The @\(attribute.attributeNameText) attribute has no effect when applied to an extension and should be removed.",
191-
severity: .error
239+
message: "Attribute \(_macroName(attribute)) has no effect when applied to an extension",
240+
severity: .error,
241+
fixIts: [
242+
FixIt(
243+
message: MacroExpansionFixItMessage("Remove attribute \(_macroName(attribute))"),
244+
changes: [.replace(oldNode: Syntax(attribute), newNode: Syntax("" as ExprSyntax))]
245+
),
246+
]
192247
)
193248
}
194249

@@ -206,19 +261,19 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
206261
case 0:
207262
return Self(
208263
syntax: Syntax(functionDecl),
209-
message: "The @\(attribute.attributeNameText) attribute cannot specify arguments when used with \(functionDecl.completeName) because it does not take any.",
264+
message: "Attribute \(_macroName(attribute)) cannot specify arguments when used with '\(functionDecl.completeName)' because it does not take any",
210265
severity: .error
211266
)
212267
case 1:
213268
return Self(
214269
syntax: Syntax(functionDecl),
215-
message: "The @\(attribute.attributeNameText) attribute must specify an argument when used with \(functionDecl.completeName).",
270+
message: "Attribute \(_macroName(attribute)) must specify an argument when used with '\(functionDecl.completeName)'",
216271
severity: .error
217272
)
218273
default:
219274
return Self(
220275
syntax: Syntax(functionDecl),
221-
message: "The @\(attribute.attributeNameText) attribute must specify \(expectedArgumentCount) arguments when used with \(functionDecl.completeName).",
276+
message: "Attribute \(_macroName(attribute)) must specify \(expectedArgumentCount) arguments when used with '\(functionDecl.completeName)'",
222277
severity: .error
223278
)
224279
}
@@ -236,7 +291,7 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
236291
static func xcTestCaseNotSupported(_ decl: some SyntaxProtocol, whenUsing attribute: AttributeSyntax) -> Self {
237292
Self(
238293
syntax: Syntax(decl),
239-
message: "The @\(attribute.attributeNameText) attribute cannot be applied to a subclass of XCTestCase.",
294+
message: "Attribute \(_macroName(attribute)) cannot be applied to a subclass of 'XCTestCase'",
240295
severity: .error
241296
)
242297
}
@@ -252,7 +307,7 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
252307
static func nonFinalClassNotSupported(_ decl: ClassDeclSyntax, whenUsing attribute: AttributeSyntax) -> Self {
253308
Self(
254309
syntax: Syntax(decl),
255-
message: "The @\(attribute.attributeNameText) attribute cannot be applied to non-final class \(decl.name.textWithoutBackticks).",
310+
message: "Attribute \(_macroName(attribute)) cannot be applied to non-final class '\(decl.name.textWithoutBackticks)'",
256311
severity: .error
257312
)
258313
}
@@ -269,7 +324,7 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
269324
static func specifierNotSupported(_ specifier: TokenSyntax, on parameter: FunctionParameterSyntax, whenUsing attribute: AttributeSyntax) -> Self {
270325
Self(
271326
syntax: Syntax(parameter),
272-
message: "The @\(attribute.attributeNameText) attribute cannot be applied to a function with a parameter marked '\(specifier.textWithoutBackticks)'.",
327+
message: "Attribute \(_macroName(attribute)) cannot be applied to a function with a parameter marked '\(specifier.textWithoutBackticks)'",
273328
severity: .error
274329
)
275330
}
@@ -286,7 +341,7 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
286341
static func returnTypeNotSupported(_ returnType: TypeSyntax, on decl: some SyntaxProtocol, whenUsing attribute: AttributeSyntax) -> Self {
287342
Self(
288343
syntax: Syntax(returnType),
289-
message: "The result of this \(_kindString(for: decl)) will be discarded during testing.",
344+
message: "The result of this \(_kindString(for: decl)) will be discarded during testing",
290345
severity: .warning
291346
)
292347
}
@@ -302,7 +357,7 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
302357
static func tagExprNotSupported(_ tagExpr: some SyntaxProtocol, in attribute: AttributeSyntax) -> Self {
303358
Self(
304359
syntax: Syntax(tagExpr),
305-
message: "The tag \(tagExpr.trimmed) cannot be used with the @\(attribute.attributeNameText) attribute. Pass a member of Tag or a string literal instead.",
360+
message: "Tag '\(tagExpr.trimmed)' cannot be used with attribute \(_macroName(attribute)); pass a member of 'Tag' or a string literal instead",
306361
severity: .error
307362
)
308363
}
@@ -317,15 +372,15 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
317372
static func optionalBoolExprIsAmbiguous(_ boolExpr: ExprSyntax) -> Self {
318373
Self(
319374
syntax: Syntax(boolExpr),
320-
message: "Requirement '\(boolExpr.trimmed)' is ambiguous.",
375+
message: "Requirement '\(boolExpr.trimmed)' is ambiguous",
321376
severity: .warning,
322377
fixIts: [
323378
FixIt(
324-
message: MacroExpansionFixItMessage("To unwrap an optional value, add 'as Bool?'."),
379+
message: MacroExpansionFixItMessage("To unwrap an optional value, add 'as Bool?'"),
325380
changes: [.replace(oldNode: Syntax(boolExpr), newNode: Syntax("\(boolExpr) as Bool?" as ExprSyntax))]
326381
),
327382
FixIt(
328-
message: MacroExpansionFixItMessage("To check if a value is true, add '?? false'."),
383+
message: MacroExpansionFixItMessage("To check if a value is true, add '?? false'"),
329384
changes: [.replace(oldNode: Syntax(boolExpr), newNode: Syntax("\(boolExpr) ?? false" as ExprSyntax))]
330385
),
331386
]

Tests/TestingMacrosTests/ConditionMacroTests.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,49 @@ struct ConditionMacroTests {
331331
#expect(diagnostics.isEmpty)
332332
}
333333

334+
@Test("#expect(try/await) produces a diagnostic",
335+
arguments: [
336+
"#expect(try foo())": ["Expression 'try foo()' will not be expanded on failure; move the throwing part out of the call to '#expect(_:_:)'"],
337+
"#expect(await foo())": ["Expression 'await foo()' will not be expanded on failure; move the asynchronous part out of the call to '#expect(_:_:)'"],
338+
"#expect(try await foo())": ["Expression 'try await foo()' will not be expanded on failure; move the throwing/asynchronous part out of the call to '#expect(_:_:)'"],
339+
"#expect(try await foo(try bar(await quux())))": [
340+
"Expression 'try await foo(try bar(await quux()))' will not be expanded on failure; move the throwing/asynchronous part out of the call to '#expect(_:_:)'",
341+
"Expression 'try bar(await quux())' will not be expanded on failure; move the throwing part out of the call to '#expect(_:_:)'",
342+
"Expression 'await quux()' will not be expanded on failure; move the asynchronous part out of the call to '#expect(_:_:)'",
343+
],
344+
345+
// Diagnoses because the diagnostic for `await` is suppressed due to the
346+
// `as T` cast, but the parentheses limit the effect of the suppression.
347+
"#expect(try (await foo() as T))": ["Expression 'try (await foo() as T)' will not be expanded on failure; move the throwing part out of the call to '#expect(_:_:)'"],
348+
]
349+
)
350+
func effectfulExpectationDiagnoses(input: String, diagnosticMessages: [String]) throws {
351+
let (_, diagnostics) = try parse(input)
352+
#expect(diagnostics.count == diagnosticMessages.count)
353+
for message in diagnosticMessages {
354+
#expect(diagnostics.contains { $0.diagMessage.message == message }, "Missing \(message): \(diagnostics.map(\.diagMessage.message))")
355+
}
356+
}
357+
358+
@Test("#expect(try/await as Bool) suppresses its diagnostic",
359+
arguments: [
360+
"#expect(try foo() as Bool)",
361+
"#expect(await foo() as Bool)",
362+
"#expect(try await foo(try await bar()) as Bool)",
363+
"#expect(try foo() as T?)",
364+
"#expect(await foo() as? T)",
365+
"#expect(try await foo(try await bar()) as! T)",
366+
"#expect((try foo()) as T)",
367+
"#expect((await foo()) as T)",
368+
"#expect((try await foo(try await bar())) as T)",
369+
"#expect(try (await foo()) as T)",
370+
]
371+
)
372+
func effectfulExpectationDiagnosticSuppressWithExplicitBool(input: String) throws {
373+
let (_, diagnostics) = try parse(input)
374+
#expect(diagnostics.isEmpty)
375+
}
376+
334377
@Test("Macro expansion is performed within a test function")
335378
func macroExpansionInTestFunction() throws {
336379
let input = ##"""

0 commit comments

Comments
 (0)