Skip to content

Commit 41fe485

Browse files
authored
Unlock expression expansions for negated expressions. (#364)
This PR enables expression expansion for expressions of the forms `!x`, `!f()`, `!y.g()`, etc. such that the value of the expressions they negate are available at runtime to inspect in a test failure. For example, consider this test: ```swift func f() -> Int { 123 } func g() -> Int { 123 } @test func myTest() { #expect(!(f() == g())) // ♦️ Expectation failed: !(f() == g()) } ``` (i.e., testing that the result of `f()` is not equal to the result of `g()`, but using `!(==)` instead of the more typical `!=`.) This is a trivial/contrived example where we can tell at a glance that the values equal `123`, of course. Still, previously this test would (correctly) fail, but would not be able to provide the return values of `f()` and `g()`. With this change, the following test issue is recorded: ```swift #expect(!(f() == g())) // ♦️ Expectation failed: !((f() → 123) == (g() → 123) → true) ``` This increasing the amount of diagnostic information available. This PR also makes adjustments to the `Expression` type to improve the information we log during test runs. In particular, when `--verbose` is passed to `swift test`, we will capture type information about more parts of a failed expectation/expression. ### 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 4c85b1a commit 41fe485

File tree

9 files changed

+265
-51
lines changed

9 files changed

+265
-51
lines changed

Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ extension Event.HumanReadableOutputRecorder {
371371
if verbose, case let .expectationFailed(expectation) = issue.kind {
372372
let expression = expectation.evaluatedExpression
373373
func addMessage(about expression: Expression) {
374-
let description = expression.expandedDescription(includingTypeNames: true, includingParenthesesIfNeeded: false)
374+
let description = expression.expandedDebugDescription()
375375
additionalMessages.append(Message(symbol: .details, stringValue: description))
376376
}
377377
let subexpressions = expression.subexpressions

Sources/Testing/Expectations/ExpectationChecking+Macro.swift

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,27 @@ public func __checkValue(
6868
isRequired: Bool,
6969
sourceLocation: SourceLocation
7070
) -> Result<Void, any Error> {
71+
// If the expression being evaluated is a negation (!x instead of x), flip
72+
// the condition here so that we evaluate it in the correct sense. We loop
73+
// in case of multiple prefix operators (!!(a == b), for example.)
74+
var condition = condition
75+
do {
76+
var expression: Expression? = expression
77+
while case let .negation(subexpression, _) = expression?.kind {
78+
defer {
79+
expression = subexpression
80+
}
81+
condition = !condition
82+
}
83+
}
84+
7185
// Capture the correct expression in the expectation.
72-
let expression = if !condition, let expressionWithCapturedRuntimeValues = expressionWithCapturedRuntimeValues() {
73-
expressionWithCapturedRuntimeValues
74-
} else {
75-
expression
86+
var expression = expression
87+
if !condition, let expressionWithCapturedRuntimeValues = expressionWithCapturedRuntimeValues() {
88+
expression = expressionWithCapturedRuntimeValues
89+
if expression.runtimeValue == nil, case .negation = expression.kind {
90+
expression = expression.capturingRuntimeValue(condition)
91+
}
7692
}
7793

7894
// Post an event for the expectation regardless of whether or not it passed.

Sources/Testing/Parameterization/TypeInfo.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,16 @@ extension TypeInfo: CustomStringConvertible, CustomDebugStringConvertible {
281281
// MARK: - Equatable, Hashable
282282

283283
extension TypeInfo: Hashable {
284+
/// Check if this instance describes a given type.
285+
///
286+
/// - Parameters:
287+
/// - type: The type to compare against.
288+
///
289+
/// - Returns: Whether or not this instance represents `type`.
290+
public func describes(_ type: Any.Type) -> Bool {
291+
self == TypeInfo(describing: type)
292+
}
293+
284294
public static func ==(lhs: Self, rhs: Self) -> Bool {
285295
switch (lhs._kind, rhs._kind) {
286296
case let (.type(lhs), .type(rhs)):

Sources/Testing/SourceAttribution/Expression+Macro.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ extension Expression {
6666
///
6767
/// - Warning: This function is used to implement the `@Test`, `@Suite`,
6868
/// `#expect()` and `#require()` macros. Do not call it directly.
69-
public static func __functionCall(_ value: Expression?, _ functionName: String, _ arguments: (label: String?, value: Expression)...) -> Self {
69+
public static func __fromFunctionCall(_ value: Expression?, _ functionName: String, _ arguments: (label: String?, value: Expression)...) -> Self {
7070
let arguments = arguments.map(Expression.Kind.FunctionCallArgument.init)
7171
return Self(kind: .functionCall(value: value, functionName: functionName, arguments: arguments))
7272
}
@@ -85,4 +85,21 @@ extension Expression {
8585
public static func __fromPropertyAccess(_ value: Expression, _ keyPath: Expression) -> Self {
8686
return Self(kind: .propertyAccess(value: value, keyPath: keyPath))
8787
}
88+
89+
/// Create an instance of ``Expression`` representing a negated expression
90+
/// using the `!` operator..
91+
///
92+
/// - Parameters:
93+
/// - expression: The expression that was negated.
94+
/// - isParenthetical: Whether or not `expression` was enclosed in
95+
/// parentheses (and the `!` operator was outside it.) This argument
96+
/// affects how this expression is represented as a string.
97+
///
98+
/// - Returns: A new instance of ``Expression``.
99+
///
100+
/// - Warning: This function is used to implement the `@Test`, `@Suite`,
101+
/// `#expect()` and `#require()` macros. Do not call it directly.
102+
public static func __fromNegation(_ expression: Expression, _ isParenthetical: Bool) -> Self {
103+
return Self(kind: .negation(expression, isParenthetical: isParenthetical))
104+
}
88105
}

Sources/Testing/SourceAttribution/Expression.swift

Lines changed: 111 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ public struct Expression: Sendable {
7878
/// - keyPath: The key path, relative to `value`, that was accessed, not
7979
/// including a leading backslash or period.
8080
indirect case propertyAccess(value: Expression, keyPath: Expression)
81+
82+
/// The expression negates another expression.
83+
///
84+
/// - Parameters:
85+
/// - expression: The expression that was negated.
86+
/// - isParenthetical: Whether or not `expression` was enclosed in
87+
/// parentheses (and the `!` operator was outside it.) This argument
88+
/// affects how this expression is represented as a string.
89+
///
90+
/// Unlike other cases in this enumeration, this case affects the runtime
91+
/// behavior of the `__check()` family of functions.
92+
indirect case negation(_ expression: Expression, isParenthetical: Bool)
8193
}
8294

8395
/// The kind of syntax node represented by this instance.
@@ -109,6 +121,12 @@ public struct Expression: Sendable {
109121
return "\(functionName)(\(argumentList))"
110122
case let .propertyAccess(value, keyPath):
111123
return "\(value.sourceCode).\(keyPath.sourceCode)"
124+
case let .negation(expression, isParenthetical):
125+
var sourceCode = expression.sourceCode
126+
if isParenthetical {
127+
sourceCode = "(\(sourceCode))"
128+
}
129+
return "!\(sourceCode)"
112130
}
113131
}
114132

@@ -192,6 +210,9 @@ public struct Expression: Sendable {
192210
func capturingRuntimeValue(_ value: (some Any)?) -> Self {
193211
var result = self
194212
result.runtimeValue = value.map { Value(reflecting: $0) }
213+
if case let .negation(subexpression, isParenthetical) = kind, let value = value as? Bool {
214+
result.kind = .negation(subexpression.capturingRuntimeValue(!value), isParenthetical: isParenthetical)
215+
}
195216
return result
196217
}
197218

@@ -237,58 +258,128 @@ public struct Expression: Sendable {
237258
value: value.capturingRuntimeValues(firstValue),
238259
keyPath: keyPath.capturingRuntimeValues(additionalValuesArray.first ?? nil)
239260
)
261+
case let .negation(expression, isParenthetical):
262+
result.kind = .negation(
263+
expression.capturingRuntimeValues(firstValue, repeat each additionalValues),
264+
isParenthetical: isParenthetical
265+
)
240266
}
241267

242268
return result
243269
}
244270

271+
/// Get an expanded description of this instance that contains the source
272+
/// code and runtime value (or values) it represents.
273+
///
274+
/// - Returns: A string describing this instance.
275+
@_spi(ForToolsIntegrationOnly)
276+
public func expandedDescription() -> String {
277+
_expandedDescription(in: _ExpandedDescriptionContext())
278+
}
279+
280+
/// Get an expanded description of this instance that contains the source
281+
/// code and runtime value (or values) it represents.
282+
///
283+
/// - Returns: A string describing this instance.
284+
///
285+
/// This function produces a more detailed description than
286+
/// ``expandedDescription()``, similar to how `String(reflecting:)` produces
287+
/// a more detailed description than `String(describing:)`.
288+
func expandedDebugDescription() -> String {
289+
var context = _ExpandedDescriptionContext()
290+
context.includeTypeNames = true
291+
context.includeParenthesesIfNeeded = false
292+
return _expandedDescription(in: context)
293+
}
294+
295+
/// A structure describing the state tracked while calling
296+
/// `_expandedDescription(in:)`.
297+
private struct _ExpandedDescriptionContext {
298+
/// The depth of recursion at which the function is being called.
299+
var depth = 0
300+
301+
/// Whether or not to include type names in output.
302+
var includeTypeNames = false
303+
304+
/// Whether or not to enclose the resulting string in parentheses (as needed
305+
/// depending on what information the resulting string contains.)
306+
var includeParenthesesIfNeeded = true
307+
}
308+
245309
/// Get an expanded description of this instance that contains the source
246310
/// code and runtime value (or values) it represents.
247311
///
248312
/// - Parameters:
249-
/// - depth: The depth of recursion at which this function is being called.
250-
/// - includingTypeNames: Whether or not to include type names in output.
251-
/// - includingParenthesesIfNeeded: Whether or not to enclose the
252-
/// resulting string in parentheses (as needed depending on what
253-
/// information this instance contains.)
313+
/// - context: The context for this call.
254314
///
255315
/// - Returns: A string describing this instance.
256-
@_spi(ForToolsIntegrationOnly)
257-
public func expandedDescription(depth: Int = 0, includingTypeNames: Bool = false, includingParenthesesIfNeeded: Bool = true) -> String {
316+
///
317+
/// This function provides the implementation of ``expandedDescription()`` and
318+
/// ``expandedDebugDescription()``.
319+
private func _expandedDescription(in context: _ExpandedDescriptionContext) -> String {
320+
// Create a (default) context value to pass to recursive calls for
321+
// subexpressions.
322+
var childContext = context
323+
do {
324+
// Bump the depth so that recursive calls track the next depth level.
325+
childContext.depth += 1
326+
327+
// Subexpressions do not automatically disable parentheses if the parent
328+
// does; they must opt in.
329+
childContext.includeParenthesesIfNeeded = true
330+
}
331+
258332
var result = ""
259333
switch kind {
260334
case let .generic(sourceCode), let .stringLiteral(sourceCode, _):
261-
result = if includingTypeNames, let qualifiedName = runtimeValue?.typeInfo.fullyQualifiedName {
335+
result = if context.includeTypeNames, let qualifiedName = runtimeValue?.typeInfo.fullyQualifiedName {
262336
"\(sourceCode): \(qualifiedName)"
263337
} else {
264338
sourceCode
265339
}
266340
case let .binaryOperation(lhsExpr, op, rhsExpr):
267-
result = "\(lhsExpr.expandedDescription(depth: depth + 1)) \(op) \(rhsExpr.expandedDescription(depth: depth + 1))"
341+
result = "\(lhsExpr._expandedDescription(in: childContext)) \(op) \(rhsExpr._expandedDescription(in: childContext))"
268342
case let .functionCall(value, functionName, arguments):
269-
let includeParentheses = arguments.count > 1
343+
var argumentContext = childContext
344+
argumentContext.includeParenthesesIfNeeded = (arguments.count > 1)
270345
let argumentList = arguments.lazy
271346
.map { argument in
272-
(argument.label, argument.value.expandedDescription(depth: depth + 1, includingParenthesesIfNeeded: includeParentheses))
347+
(argument.label, argument.value._expandedDescription(in: argumentContext))
273348
}.map { label, value in
274349
if let label {
275350
return "\(label): \(value)"
276351
}
277352
return value
278353
}.joined(separator: ", ")
279354
result = if let value {
280-
"\(value.expandedDescription(depth: depth + 1)).\(functionName)(\(argumentList))"
355+
"\(value._expandedDescription(in: childContext)).\(functionName)(\(argumentList))"
281356
} else {
282357
"\(functionName)(\(argumentList))"
283358
}
284359
case let .propertyAccess(value, keyPath):
285-
result = "\(value.expandedDescription(depth: depth + 1)).\(keyPath.expandedDescription(depth: depth + 1, includingParenthesesIfNeeded: false))"
360+
var keyPathContext = childContext
361+
keyPathContext.includeParenthesesIfNeeded = false
362+
result = "\(value._expandedDescription(in: childContext)).\(keyPath._expandedDescription(in: keyPathContext))"
363+
case let .negation(expression, isParenthetical):
364+
childContext.includeParenthesesIfNeeded = !isParenthetical
365+
var expandedDescription = expression._expandedDescription(in: childContext)
366+
if isParenthetical {
367+
expandedDescription = "(\(expandedDescription))"
368+
}
369+
result = "!\(expandedDescription)"
286370
}
287371

288-
// If this expression is at the root of the expression graph and has no
289-
// value, don't bother reporting the placeholder string for it.
290-
if depth == 0 && runtimeValue == nil {
291-
return result
372+
// If this expression is at the root of the expression graph...
373+
if context.depth == 0 {
374+
if runtimeValue == nil {
375+
// ... and has no value, don't bother reporting the placeholder string
376+
// for it...
377+
return result
378+
} else if let runtimeValue, runtimeValue.typeInfo.describes(Bool.self) {
379+
// ... or if it is a boolean value, also don't bother (because it can be
380+
// inferred from context.)
381+
return result
382+
}
292383
}
293384

294385
let runtimeValueDescription = runtimeValue.map(String.init(describing:)) ?? "<not evaluated>"
@@ -297,7 +388,7 @@ public struct Expression: Sendable {
297388
result
298389
} else if runtimeValueDescription == result {
299390
result
300-
} else if includingParenthesesIfNeeded && depth > 0 {
391+
} else if context.includeParenthesesIfNeeded && context.depth > 0 {
301392
"(\(result)\(runtimeValueDescription))"
302393
} else {
303394
"\(result)\(runtimeValueDescription)"
@@ -322,6 +413,8 @@ public struct Expression: Sendable {
322413
}
323414
case let .propertyAccess(value: value, keyPath: keyPath):
324415
[value, keyPath]
416+
case let .negation(expression, _):
417+
[expression]
325418
}
326419
}
327420

Sources/TestingMacros/Support/ConditionArgumentParsing.swift

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ private func _diagnoseTrivialBooleanValue(from expr: ExprSyntax, for macro: some
7474
default:
7575
break
7676
}
77-
} else if let literal = _negatedExpression(expr, in: context)?.as(BooleanLiteralExprSyntax.self) {
77+
} else if let literal = _negatedExpression(expr)?.0.as(BooleanLiteralExprSyntax.self) {
7878
// This expression is of the form !true or !false.
7979
switch literal.literal.tokenKind {
8080
case .keyword(.true):
@@ -97,11 +97,15 @@ private func _diagnoseTrivialBooleanValue(from expr: ExprSyntax, for macro: some
9797
/// negation expression.
9898
///
9999
/// This function handles expressions such as `!foo` or `!(bar)`.
100-
private func _negatedExpression(_ expr: ExprSyntax, in context: some MacroExpansionContext) -> ExprSyntax? {
100+
private func _negatedExpression(_ expr: ExprSyntax) -> (ExprSyntax, isParenthetical: Bool)? {
101101
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+
if let negatedExpr = removeParentheses(from: op.expression) {
105+
return (negatedExpr, true)
106+
} else {
107+
return (op.expression, false)
108+
}
105109
}
106110

107111
return nil
@@ -443,6 +447,23 @@ private func _parseCondition(from expr: MemberAccessExprSyntax, for macro: some
443447
)
444448
}
445449

450+
/// Parse a condition argument from a property access.
451+
///
452+
/// - Parameters:
453+
/// - expr: The expression that was negated.
454+
/// - isParenthetical: Whether or not `expression` was enclosed in
455+
/// parentheses (and the `!` operator was outside it.) This argument
456+
/// affects how this expression is represented as a string.
457+
/// - macro: The macro expression being expanded.
458+
/// - context: The macro context in which the expression is being parsed.
459+
///
460+
/// - Returns: An instance of ``Condition`` describing `expr`.
461+
private func _parseCondition(negating expr: ExprSyntax, isParenthetical: Bool, for macro: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext) -> Condition {
462+
var result = _parseCondition(from: expr, for: macro, in: context)
463+
result.expression = createExpressionExprForNegation(of: result.expression, isParenthetical: isParenthetical)
464+
return result
465+
}
466+
446467
/// Parse a condition argument from an arbitrary expression.
447468
///
448469
/// - Parameters:
@@ -476,6 +497,11 @@ private func _parseCondition(from expr: ExprSyntax, for macro: some Freestanding
476497
return _parseCondition(from: memberAccessExpr, for: macro, in: context)
477498
}
478499

500+
// Handle negation.
501+
if let negatedExpr = _negatedExpression(expr) {
502+
return _parseCondition(negating: negatedExpr.0, isParenthetical: negatedExpr.isParenthetical, for: macro, in: context)
503+
}
504+
479505
// Parentheses are parsed as if they were tuples, so (true && false) appears
480506
// to the parser as a tuple containing one expression, `true && false`.
481507
if let expr = removeParentheses(from: expr) {
@@ -496,9 +522,7 @@ private func _parseCondition(from expr: ExprSyntax, for macro: some Freestanding
496522
///
497523
/// - Returns: An instance of ``Condition`` describing `expr`.
498524
func parseCondition(from expr: ExprSyntax, for macro: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext) -> Condition {
525+
_diagnoseTrivialBooleanValue(from: expr, for: macro, in: context)
499526
let result = _parseCondition(from: expr, for: macro, in: context)
500-
if result.arguments.count == 1, let onlyArgument = result.arguments.first {
501-
_diagnoseTrivialBooleanValue(from: onlyArgument.expression, for: macro, in: context)
502-
}
503527
return result
504528
}

0 commit comments

Comments
 (0)