Skip to content

Commit 3a77507

Browse files
authored
Diagnose ambiguous uses of #require() with an argument of type Bool?. (#276)
This PR adds new diagnostics when `#require()` is passed an argument of type `Bool?` because such an argument may be ambiguously interpreted. If the test author writes: ```swift try #require(love?.isFleeting) ``` Do they mean to unwrap the optional boolean value and return it, or do they mean to test if `isFleeting` is true or false? With this change, an expression such as that one that returns a value of type `Bool?` will produce a diagnostic of the form: ![Screenshot 2024-03-08 at 4 41 30 PM](https://github.com/apple/swift-testing/assets/4145863/248105f8-1d48-4c9b-96b9-e6e3b066b4ca) (The diagnostics are presented as fix-its in Xcode.) Resolves rdar://105727852. ### 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 e9f57f6 commit 3a77507

File tree

7 files changed

+152
-12
lines changed

7 files changed

+152
-12
lines changed

Sources/Testing/Expectations/Expectation+Macro.swift

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,18 +57,49 @@
5757
/// - sourceLocation: The source location to which recorded expectations and
5858
/// issues should be attributed.
5959
///
60-
/// - Returns: The unwrapped value of `value`.
60+
/// - Returns: The unwrapped value of `optionalValue`.
6161
///
62-
/// - Throws: An instance of ``ExpectationFailedError`` if `value` is `nil`.
62+
/// - Throws: An instance of ``ExpectationFailedError`` if `optionalValue` is
63+
/// `nil`.
6364
///
64-
/// If `value` is `nil`, an ``Issue`` is recorded for the test that is running
65-
/// in the current task and an instance of ``ExpectationFailedError`` is thrown.
65+
/// If `optionalValue` is `nil`, an ``Issue`` is recorded for the test that is
66+
/// running in the current task and an instance of ``ExpectationFailedError`` is
67+
/// thrown.
6668
@freestanding(expression) public macro require<T>(
6769
_ optionalValue: T?,
6870
_ comment: @autoclosure () -> Comment? = nil,
6971
sourceLocation: SourceLocation = SourceLocation()
7072
) -> T = #externalMacro(module: "TestingMacros", type: "RequireMacro")
7173

74+
/// Unwrap an optional boolean value or, if it is `nil`, fail and throw an
75+
/// error.
76+
///
77+
/// - Parameters:
78+
/// - optionalValue: The optional value to be unwrapped.
79+
/// - comment: A comment describing the expectation.
80+
/// - sourceLocation: The source location to which recorded expectations and
81+
/// issues should be attributed.
82+
///
83+
/// - Returns: The unwrapped value of `optionalValue`.
84+
///
85+
/// - Throws: An instance of ``ExpectationFailedError`` if `optionalValue` is
86+
/// `nil`.
87+
///
88+
/// If `optionalValue` is `nil`, an ``Issue`` is recorded for the test that is
89+
/// running in the current task and an instance of ``ExpectationFailedError`` is
90+
/// thrown.
91+
///
92+
/// This overload of ``require(_:_:sourceLocation:)-6w9oo`` checks if
93+
/// `optionalValue` may be ambiguous (i.e. it is unclear if the developer
94+
/// intended to check for a boolean value or unwrap an optional boolean value)
95+
/// and provides additional compile-time diagnostics when it is.
96+
@_documentation(visibility: private)
97+
@freestanding(expression) public macro require(
98+
_ optionalValue: Bool?,
99+
_ comment: @autoclosure () -> Comment? = nil,
100+
sourceLocation: SourceLocation = SourceLocation()
101+
) -> Bool = #externalMacro(module: "TestingMacros", type: "AmbiguousRequireMacro")
102+
72103
// MARK: - Matching errors by type
73104

74105
/// Check that an expression always throws an error of a given type.

Sources/TestingMacros/ConditionMacro.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,42 @@ public struct RequireMacro: ConditionMacro {
202202
true
203203
}
204204
}
205+
206+
/// A type describing the expansion of the `#require()` macro when it is
207+
/// ambiguous whether it refers to a boolean check or optional unwrapping.
208+
///
209+
/// This type is otherwise exactly equivalent to ``RequireMacro``.
210+
public struct AmbiguousRequireMacro: ConditionMacro {
211+
public static func expansion(
212+
of macro: some FreestandingMacroExpansionSyntax,
213+
in context: some MacroExpansionContext
214+
) throws -> ExprSyntax {
215+
if let argument = macro.argumentList.first {
216+
_checkAmbiguousArgument(argument.expression, in: context)
217+
}
218+
219+
// Perform the normal macro expansion for #require().
220+
return try RequireMacro.expansion(of: macro, in: context)
221+
}
222+
223+
private static func _checkAmbiguousArgument(_ argument: ExprSyntax, in context: some MacroExpansionContext) {
224+
// If the argument is wrapped in parentheses, strip them before continuing.
225+
if let argumentWithoutParentheses = removeParentheses(from: argument) {
226+
return _checkAmbiguousArgument(argumentWithoutParentheses, in: context)
227+
}
228+
229+
// If the argument is explicitly an as? cast already, do not diagnose.
230+
if argument.is(AsExprSyntax.self) {
231+
return
232+
}
233+
234+
// If we reach this point, then the argument appears to be an ambiguous
235+
// expression and we aren't sure if the developer intended to unwrap a Bool?
236+
// or check the value of the wrapped Bool.
237+
context.diagnose(.optionalBoolExprIsAmbiguous(argument))
238+
}
239+
240+
public static var isThrowing: Bool {
241+
true
242+
}
243+
}

Sources/TestingMacros/Support/ConditionArgumentParsing.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,10 @@ private func _diagnoseTrivialBooleanValue(from expr: ExprSyntax, for macro: some
9898
///
9999
/// This function handles expressions such as `!foo` or `!(bar)`.
100100
private func _negatedExpression(_ expr: ExprSyntax, in context: some MacroExpansionContext) -> ExprSyntax? {
101-
let expr = _removeParentheses(from: expr) ?? expr
101+
let expr = removeParentheses(from: expr) ?? expr
102102
if let op = expr.as(PrefixOperatorExprSyntax.self),
103103
op.operator.tokenKind == .prefixOperator("!") {
104-
return _removeParentheses(from: op.expression) ?? op.expression
104+
return removeParentheses(from: op.expression) ?? op.expression
105105
}
106106

107107
return nil
@@ -117,12 +117,12 @@ private func _negatedExpression(_ expr: ExprSyntax, in context: some MacroExpans
117117
///
118118
/// This function handles expressions such as `(foo)` or `((foo, bar))`. It does
119119
/// not remove interior parentheses (e.g. `(foo, (bar))`.)
120-
private func _removeParentheses(from expr: ExprSyntax) -> ExprSyntax? {
120+
func removeParentheses(from expr: ExprSyntax) -> ExprSyntax? {
121121
if let tuple = expr.as(TupleExprSyntax.self),
122122
tuple.elements.count == 1,
123123
let elementExpr = tuple.elements.first,
124124
elementExpr.label == nil {
125-
return _removeParentheses(from: elementExpr.expression) ?? elementExpr.expression
125+
return removeParentheses(from: elementExpr.expression) ?? elementExpr.expression
126126
}
127127

128128
return nil
@@ -478,7 +478,7 @@ private func _parseCondition(from expr: ExprSyntax, for macro: some Freestanding
478478

479479
// Parentheses are parsed as if they were tuples, so (true && false) appears
480480
// to the parser as a tuple containing one expression, `true && false`.
481-
if let expr = _removeParentheses(from: expr) {
481+
if let expr = removeParentheses(from: expr) {
482482
return _parseCondition(from: expr, for: macro, in: context)
483483
}
484484

Sources/TestingMacros/Support/DiagnosticMessage.swift

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import SwiftDiagnostics
1212
#if swift(>=5.11)
1313
import SwiftSyntax
1414
import SwiftSyntaxMacros
15+
import SwiftSyntaxMacroExpansion
1516
#else
1617
public import SwiftSyntax
1718
public import SwiftSyntaxMacros
19+
public import SwiftSyntaxMacroExpansion
1820
#endif
1921

2022
/// A type describing diagnostic messages emitted by this module's macro during
@@ -293,8 +295,7 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
293295
/// tag on a test or suite is not supported.
294296
///
295297
/// - Parameters:
296-
/// - returnType: The unsupported return type.
297-
/// - decl: The declaration with an unsupported return type.
298+
/// - tagExpr: The unsupported tag expression.
298299
/// - attribute: The `@Test` or `@Suite` attribute.
299300
///
300301
/// - Returns: A diagnostic message.
@@ -306,13 +307,39 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
306307
)
307308
}
308309

310+
/// Create a diagnostic messages stating that the expression passed to
311+
/// `#require()` is ambiguous.
312+
///
313+
/// - Parameters:
314+
/// - boolExpr: The ambiguous optional boolean expression.
315+
///
316+
/// - Returns: A diagnostic message.
317+
static func optionalBoolExprIsAmbiguous(_ boolExpr: ExprSyntax) -> Self {
318+
Self(
319+
syntax: Syntax(boolExpr),
320+
message: "Requirement '\(boolExpr.trimmed)' is ambiguous.",
321+
severity: .warning,
322+
fixIts: [
323+
FixIt(
324+
message: MacroExpansionFixItMessage("To unwrap an optional value, add 'as Bool?'."),
325+
changes: [.replace(oldNode: Syntax(boolExpr), newNode: Syntax("\(boolExpr) as Bool?" as ExprSyntax))]
326+
),
327+
FixIt(
328+
message: MacroExpansionFixItMessage("To check if a value is true, add '?? false'."),
329+
changes: [.replace(oldNode: Syntax(boolExpr), newNode: Syntax("\(boolExpr) ?? false" as ExprSyntax))]
330+
),
331+
]
332+
)
333+
}
334+
309335
var syntax: Syntax
310336

311337
// MARK: - DiagnosticMessage
312338

313339
var message: String
314340
var diagnosticID = MessageID(domain: "org.swift.testing", id: "macros")
315341
var severity: DiagnosticSeverity
342+
var fixIts: [FixIt] = []
316343
}
317344

318345
// MARK: -
@@ -324,12 +351,14 @@ extension MacroExpansionContext {
324351
/// - message: The diagnostic message to emit. The `node` and `position`
325352
/// arguments to `Diagnostic.init()` are derived from the message's
326353
/// `syntax` property.
354+
/// - fixIts: Any Fix-Its to apply.
327355
func diagnose(_ message: DiagnosticMessage) {
328356
diagnose(
329357
Diagnostic(
330358
node: message.syntax,
331359
position: message.syntax.positionAfterSkippingLeadingTrivia,
332-
message: message
360+
message: message,
361+
fixIts: message.fixIts
333362
)
334363
)
335364
}

Sources/TestingMacros/TestingMacrosMain.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ struct TestingMacrosMain: CompilerPlugin {
2626
TestDeclarationMacro.self,
2727
ExpectMacro.self,
2828
RequireMacro.self,
29+
AmbiguousRequireMacro.self,
2930
TagMacro.self,
3031
]
3132
}

Tests/TestingMacrosTests/ConditionMacroTests.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,45 @@ struct ConditionMacroTests {
292292
#expect(diagnostics.count == 0)
293293
}
294294

295+
@Test("#require(Bool?) produces a diagnostic",
296+
arguments: [
297+
"#requireAmbiguous(expression)",
298+
"#requireAmbiguous((expression))",
299+
"#requireAmbiguous(a + b)",
300+
"#requireAmbiguous((a + b))",
301+
"#requireAmbiguous((a) + (b))",
302+
]
303+
)
304+
func requireOptionalBoolProducesDiagnostic(input: String) throws {
305+
let (_, diagnostics) = try parse(input)
306+
307+
let diagnostic = try #require(diagnostics.first)
308+
#expect(diagnostic.diagMessage.severity == .warning)
309+
#expect(diagnostic.message.contains("is ambiguous"))
310+
#expect(diagnostic.fixIts.count == 2)
311+
#expect(diagnostic.fixIts[0].message.message.contains("as Bool?"))
312+
#expect(diagnostic.fixIts[1].message.message.contains("?? false"))
313+
}
314+
315+
@Test("#require(as Bool?) suppresses its diagnostic",
316+
arguments: [
317+
"#requireAmbiguous(expression as Bool?)",
318+
"#requireAmbiguous((expression as Bool?))",
319+
"#requireAmbiguous((expression) as Bool?)",
320+
"#requireAmbiguous(a + b as Bool?)",
321+
"#requireAmbiguous((a + b) as Bool?)",
322+
"#requireAmbiguous((a) + (b) as Bool?)",
323+
"#requireAmbiguous(((a) + (b)) as Bool?)",
324+
]
325+
)
326+
func requireOptionalBoolSuppressedWithExplicitType(input: String) throws {
327+
// Note we do not need to test "as Bool" (non-optional) because an
328+
// expression of type Bool rather than Bool? won't trigger the additional
329+
// diagnostics in the first place.
330+
let (_, diagnostics) = try parse(input)
331+
#expect(diagnostics.isEmpty)
332+
}
333+
295334
@Test("Macro expansion is performed within a test function")
296335
func macroExpansionInTestFunction() throws {
297336
let input = ##"""

Tests/TestingMacrosTests/TestSupport/Parse.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import SwiftSyntaxMacroExpansion
2121
fileprivate let allMacros: [String: any Macro.Type] = [
2222
"expect": ExpectMacro.self,
2323
"require": RequireMacro.self,
24+
"requireAmbiguous": AmbiguousRequireMacro.self, // different name needed only for unit testing
2425
"Suite": SuiteDeclarationMacro.self,
2526
"Test": TestDeclarationMacro.self,
2627
]

0 commit comments

Comments
 (0)