Skip to content

Commit 097db6c

Browse files
authored
Warn when passing a non-optional value to try #require(T?). (#620)
This PR adds an overload of `try #require()` that warns the developer if they pass a non-optional, non-`Bool` value For example, this code: ```swift let x = 0 let y = try #require(x) ``` Will produce the diagnostic: > ⚠️ '#require(\_:\_:)' is redundant because 'x' never equals 'nil' ### 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 22b0ef1 commit 097db6c

File tree

9 files changed

+90
-9
lines changed

9 files changed

+90
-9
lines changed

Sources/Testing/Expectations/Expectation+Macro.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,34 @@ public macro require(
101101
sourceLocation: SourceLocation = #_sourceLocation
102102
) -> Bool = #externalMacro(module: "TestingMacros", type: "AmbiguousRequireMacro")
103103

104+
/// Unwrap an optional value or, if it is `nil`, fail and throw an error.
105+
///
106+
/// - Parameters:
107+
/// - optionalValue: The optional value to be unwrapped.
108+
/// - comment: A comment describing the expectation.
109+
/// - sourceLocation: The source location to which recorded expectations and
110+
/// issues should be attributed.
111+
///
112+
/// - Returns: The unwrapped value of `optionalValue`.
113+
///
114+
/// - Throws: An instance of ``ExpectationFailedError`` if `optionalValue` is
115+
/// `nil`.
116+
///
117+
/// If `optionalValue` is `nil`, an ``Issue`` is recorded for the test that is
118+
/// running in the current task and an instance of ``ExpectationFailedError`` is
119+
/// thrown.
120+
///
121+
/// This overload of ``require(_:_:sourceLocation:)-6w9oo`` is used when a
122+
/// non-optional, non-`Bool` value is passed to `#require()`. It emits a warning
123+
/// diagnostic indicating that the expectation is redundant.
124+
@freestanding(expression)
125+
@_documentation(visibility: private)
126+
public macro require<T>(
127+
_ optionalValue: T,
128+
_ comment: @autoclosure () -> Comment? = nil,
129+
sourceLocation: SourceLocation = #_sourceLocation
130+
) -> T = #externalMacro(module: "TestingMacros", type: "NonOptionalRequireMacro")
131+
104132
// MARK: - Matching errors by type
105133

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

Sources/TestingMacros/ConditionMacro.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,26 @@ public struct AmbiguousRequireMacro: RefinedConditionMacro {
293293
}
294294
}
295295

296+
/// A type describing the expansion of the `#require()` macro when it is passed
297+
/// a non-optional, non-`Bool` value.
298+
///
299+
/// This type is otherwise exactly equivalent to ``RequireMacro``.
300+
public struct NonOptionalRequireMacro: RefinedConditionMacro {
301+
public typealias Base = RequireMacro
302+
303+
public static func expansion(
304+
of macro: some FreestandingMacroExpansionSyntax,
305+
in context: some MacroExpansionContext
306+
) throws -> ExprSyntax {
307+
if let argument = macro.arguments.first {
308+
context.diagnose(.nonOptionalRequireIsRedundant(argument.expression, in: macro))
309+
}
310+
311+
// Perform the normal macro expansion for #require().
312+
return try RequireMacro.expansion(of: macro, in: context)
313+
}
314+
}
315+
296316
// MARK: -
297317

298318
/// A syntax visitor that looks for uses of `#expect()` and `#require()` nested

Sources/TestingMacros/Support/DiagnosticMessage.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,26 @@ struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage {
670670
)
671671
}
672672

673+
/// Create a diagnostic messages stating that the expression passed to
674+
/// `#require()` is not optional and the macro is redundant.
675+
///
676+
/// - Parameters:
677+
/// - expr: The non-optional expression.
678+
///
679+
/// - Returns: A diagnostic message.
680+
static func nonOptionalRequireIsRedundant(_ expr: ExprSyntax, in macro: some FreestandingMacroExpansionSyntax) -> Self {
681+
// We do not provide fix-its because we cannot see the leading "try" keyword
682+
// so we can't provide a valid fix-it to remove the macro either. We can
683+
// provide a fix-it to add "as Optional", but only providing that fix-it may
684+
// confuse or mislead developers (and that's presumably usually the *wrong*
685+
// fix-it to select anyway.)
686+
Self(
687+
syntax: Syntax(expr),
688+
message: "\(_macroName(macro)) is redundant because '\(expr.trimmed)' never equals 'nil'",
689+
severity: .warning
690+
)
691+
}
692+
673693
/// Create a diagnostic message stating that a condition macro nested inside
674694
/// an exit test will not record any diagnostics.
675695
///

Sources/TestingMacros/TestingMacrosMain.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ struct TestingMacrosMain: CompilerPlugin {
2323
ExpectMacro.self,
2424
RequireMacro.self,
2525
AmbiguousRequireMacro.self,
26+
NonOptionalRequireMacro.self,
2627
ExitTestExpectMacro.self,
2728
ExitTestRequireMacro.self,
2829
TagMacro.self,

Tests/TestingMacrosTests/ConditionMacroTests.swift

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

334+
@Test("#require(non-optional value) produces a diagnostic",
335+
arguments: [
336+
"#requireNonOptional(expression)",
337+
]
338+
)
339+
func requireNonOptionalProducesDiagnostic(input: String) throws {
340+
let (_, diagnostics) = try parse(input)
341+
342+
let diagnostic = try #require(diagnostics.first)
343+
#expect(diagnostic.diagMessage.severity == .warning)
344+
#expect(diagnostic.message.contains("is redundant"))
345+
}
346+
334347
#if !SWT_NO_EXIT_TESTS
335348
@Test("Expectation inside an exit test diagnoses",
336349
arguments: [

Tests/TestingMacrosTests/TestSupport/Parse.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ fileprivate let allMacros: [String: any Macro.Type] = [
2222
"expect": ExpectMacro.self,
2323
"require": RequireMacro.self,
2424
"requireAmbiguous": AmbiguousRequireMacro.self, // different name needed only for unit testing
25+
"requireNonOptional": NonOptionalRequireMacro.self, // different name needed only for unit testing
2526
"expectExitTest": ExitTestRequireMacro.self, // different name needed only for unit testing
2627
"requireExitTest": ExitTestRequireMacro.self, // different name needed only for unit testing
2728
"Suite": SuiteDeclarationMacro.self,

Tests/TestingTests/BacktraceTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ struct BacktraceTests {
3434
}
3535

3636
@Test("Backtrace.current() is populated")
37-
func currentBacktrace() throws {
38-
let backtrace = try #require(Backtrace.current())
37+
func currentBacktrace() {
38+
let backtrace = Backtrace.current()
3939
#expect(!backtrace.addresses.isEmpty)
4040
}
4141

Tests/TestingTests/IssueTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ final class IssueTests: XCTestCase {
113113

114114
await Test {
115115
let x: String? = nil
116-
_ = try #require(x ?? "hello")
116+
_ = try #require(x ?? ("hello" as String?))
117117
}.run(configuration: configuration)
118118
}
119119

Tests/TestingTests/MiscellaneousTests.swift

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -369,9 +369,8 @@ struct MiscellaneousTests {
369369
#expect(firstParameter.index == 0)
370370
#expect(firstParameter.firstName == "i")
371371
#expect(firstParameter.secondName == nil)
372-
let firstParameterTypeInfo = try #require(firstParameter.typeInfo)
373-
#expect(firstParameterTypeInfo.fullyQualifiedName == "Swift.Int")
374-
#expect(firstParameterTypeInfo.unqualifiedName == "Int")
372+
#expect(firstParameter.typeInfo.fullyQualifiedName == "Swift.Int")
373+
#expect(firstParameter.typeInfo.unqualifiedName == "Int")
375374
} catch {}
376375

377376
do {
@@ -386,9 +385,8 @@ struct MiscellaneousTests {
386385
#expect(secondParameter.index == 1)
387386
#expect(secondParameter.firstName == "j")
388387
#expect(secondParameter.secondName == "k")
389-
let secondParameterTypeInfo = try #require(secondParameter.typeInfo)
390-
#expect(secondParameterTypeInfo.fullyQualifiedName == "Swift.String")
391-
#expect(secondParameterTypeInfo.unqualifiedName == "String")
388+
#expect(secondParameter.typeInfo.fullyQualifiedName == "Swift.String")
389+
#expect(secondParameter.typeInfo.unqualifiedName == "String")
392390
} catch {}
393391
}
394392

0 commit comments

Comments
 (0)