Skip to content

Commit b1a679f

Browse files
committed
Rethink how we capture expectation conditions and their subexpressions.
This PR completely rewrites how we capture expectation conditions. For example, given the following expectation: ```swift ``` We currently detect that there is a binary operation and emit code that calls the binary operator as a closure and passes the left-hand value and right-hand value, then checks that the result of the operation is `true`. This is sufficient for simpler expressions like that one, but more complex ones (including any that involve `try` or `await` keywords) cannot be expanded correctly. With this PR, such expressions _can_ generally be expanded correctly. The change involves rewriting the macro condition as a closure to which is passed a local, mutable "context" value. Subexpressions of the condition expression are then rewritten by walking the syntax tree of the expression (using typical swift-syntax API) and replacing them with calls into the context value that pass in the value and related state. If the expectation ultimately fails, the collected data is transformed into an instance of the SPI type `Expression` that contains the source code of the expression and interesting subexpressions as well as the runtime values of those subexpressions. Nodes in the syntax tree are identified by a unique ID which is composed of the swift-syntax ID for that node as well as all its parent nodes in a compact bitmask format. These IDs can be transformed into graph/trie key paths when expression/subexpression relationships need to be reconstructed on failure, meaning that a single rewritten node doesn't otherwise need to know its "place" in the overall expression. There remain a few caveats (that also generally affect the current implementation): - Mutating member functions are syntactically indistinguishable from non-mutating ones and miscompile when rewritten; - Expressions involving move-only types are also indistinguishable, but need lifetime management to be rewritten correctly; and - Expressions where the `try` or `await` keyword is _outside_ the `#expect` macro cannot be expanded correctly because the macro cannot see those keywords during expansion. The first issue might be resolvable in the future using pointer tricks, although I don't hold a lot of hope for it. The second issue is probably resolved by non-escaping types. The third issue is an area of active exploration for us and the macros/swift-syntax team.
1 parent 0fd04d1 commit b1a679f

26 files changed

+1484
-1764
lines changed

Package.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ let package = Package(
5656
],
5757
swiftSettings: .packageSettings
5858
),
59+
.testTarget(
60+
name: "SubexpressionShowcase",
61+
dependencies: [
62+
"Testing",
63+
],
64+
swiftSettings: .packageSettings
65+
),
5966

6067
.macro(
6168
name: "TestingMacros",

Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedMessage.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,20 @@ extension ABIv0 {
6161
/// The symbol associated with this message.
6262
var symbol: Symbol
6363

64+
/// The amount of extra padding to insert between the symbol and string
65+
/// value when presenting this message.
66+
///
67+
/// - Warning: This property is not yet part of the JSON schema.
68+
var _padding: Int?
69+
6470
/// The human-readable, unformatted text associated with this message.
6571
var text: String
6672

6773
init(encoding message: borrowing Event.HumanReadableOutputRecorder.Message) {
6874
symbol = Symbol(encoding: message.symbol ?? .default)
75+
if message.padding > 0 {
76+
_padding = message.padding
77+
}
6978
text = message.conciseStringValue ?? message.stringValue
7079
}
7180
}

Sources/Testing/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ add_library(Testing
6161
SourceAttribution/Backtrace+Symbolication.swift
6262
SourceAttribution/CustomTestStringConvertible.swift
6363
SourceAttribution/Expression.swift
64-
SourceAttribution/Expression+Macro.swift
64+
SourceAttribution/ExpressionID.swift
6565
SourceAttribution/SourceContext.swift
6666
SourceAttribution/SourceLocation.swift
6767
SourceAttribution/SourceLocation+Macro.swift

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,11 @@ extension Event.Symbol {
169169
case .attachment:
170170
return "\(_ansiEscapeCodePrefix)94m\(symbolCharacter)\(_resetANSIEscapeCode)"
171171
case .details:
172-
return symbolCharacter
172+
var padding = " "
173+
#if os(macOS) || (os(iOS) && targetEnvironment(macCatalyst))
174+
padding = " "
175+
#endif
176+
return "\(padding)\(symbolCharacter)"
173177
}
174178
}
175179
return "\(symbolCharacter)"
@@ -306,14 +310,15 @@ extension Event.ConsoleOutputRecorder {
306310
let messages = _humanReadableOutputRecorder.record(event, in: context)
307311
for message in messages {
308312
let symbol = message.symbol?.stringValue(options: options) ?? " "
313+
let padding = String(repeating: " ", count: message.padding)
309314

310315
if case .details = message.symbol, options.useANSIEscapeCodes, options.ansiColorBitDepth > 1 {
311316
// Special-case the detail symbol to apply grey to the entire line of
312317
// text instead of just the symbol.
313-
write("\(_ansiEscapeCodePrefix)90m\(symbol) \(message.stringValue)\(_resetANSIEscapeCode)\n")
318+
write("\(_ansiEscapeCodePrefix)90m\(symbol) \(padding)\(message.stringValue)\(_resetANSIEscapeCode)\n")
314319
} else {
315320
let colorDots = context.test.map(\.tags).map { self.colorDots(for: $0) } ?? ""
316-
write("\(symbol) \(colorDots)\(message.stringValue)\n")
321+
write("\(symbol) \(padding)\(colorDots)\(message.stringValue)\n")
317322
}
318323
}
319324
return !messages.isEmpty

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

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ extension Event {
2525
/// The symbol associated with this message, if any.
2626
var symbol: Symbol?
2727

28+
/// The amount of extra padding to insert between the symbol and string
29+
/// value when presenting this message.
30+
///
31+
/// The nature of this additional padding depends on how the message is
32+
/// presented; typically, the greater the value of this property, the more
33+
/// whitespace characters are inserted.
34+
var padding = 0
35+
2836
/// The human-readable message.
2937
var stringValue: String
3038

@@ -427,20 +435,22 @@ extension Event.HumanReadableOutputRecorder {
427435
}
428436
additionalMessages += _formattedComments(issue.comments)
429437

430-
if verbosity > 0, case let .expectationFailed(expectation) = issue.kind {
438+
if verbosity >= 0, case let .expectationFailed(expectation) = issue.kind {
431439
let expression = expectation.evaluatedExpression
432-
func addMessage(about expression: __Expression) {
433-
let description = expression.expandedDebugDescription()
434-
additionalMessages.append(Message(symbol: .details, stringValue: description))
435-
}
436-
let subexpressions = expression.subexpressions
437-
if subexpressions.isEmpty {
438-
addMessage(about: expression)
439-
} else {
440-
for subexpression in subexpressions {
441-
addMessage(about: subexpression)
440+
func addMessage(about expression: __Expression, depth: Int) {
441+
let description = if verbosity <= 0 {
442+
expression.expandedDescription()
443+
} else {
444+
expression.expandedDebugDescription()
445+
}
446+
if description != expression.sourceCode {
447+
additionalMessages.append(Message(symbol: .details, padding: depth, stringValue: description))
448+
}
449+
for subexpression in expression.subexpressions {
450+
addMessage(about: subexpression, depth: depth + 1)
442451
}
443452
}
453+
addMessage(about: expression, depth: 0)
444454
}
445455

446456
let atSourceLocation = issue.sourceLocation.map { " at \($0)" } ?? ""

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ extension ExitTest {
241241
func callExitTest(
242242
exitsWith expectedExitCondition: ExitCondition,
243243
observing observedValues: [any PartialKeyPath<ExitTestArtifacts> & Sendable],
244-
expression: __Expression,
244+
sourceCode: String,
245245
comments: @autoclosure () -> [Comment],
246246
isRequired: Bool,
247247
isolation: isolated (any Actor)? = #isolation,
@@ -293,10 +293,13 @@ func callExitTest(
293293
let actualExitCondition = result.exitCondition
294294

295295
// Plumb the exit test's result through the general expectation machinery.
296-
return __checkValue(
296+
var expectationContext = __ExpectationContext()
297+
expectationContext.sourceCode[""] = sourceCode
298+
expectationContext.runtimeValues[""] = { Expression.Value(reflecting: actualExitCondition) }
299+
return check(
297300
expectedExitCondition == actualExitCondition,
298-
expression: expression,
299-
expressionWithCapturedRuntimeValues: expression.capturingRuntimeValues(actualExitCondition),
301+
expectationContext: expectationContext,
302+
mismatchedErrorDescription: nil,
300303
mismatchedExitConditionDescription: String(describingForTest: expectedExitCondition),
301304
comments: comments(),
302305
isRequired: isRequired,

Sources/Testing/Expectations/Expectation+Macro.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,10 @@
6666
/// running in the current task and an instance of ``ExpectationFailedError`` is
6767
/// thrown.
6868
@freestanding(expression) public macro require<T>(
69-
_ optionalValue: T?,
69+
_ optionalValue: consuming T?,
7070
_ comment: @autoclosure () -> Comment? = nil,
7171
sourceLocation: SourceLocation = #_sourceLocation
72-
) -> T = #externalMacro(module: "TestingMacros", type: "RequireMacro")
72+
) -> T = #externalMacro(module: "TestingMacros", type: "RequireMacro") where T: ~Copyable
7373

7474
/// Unwrap an optional boolean value or, if it is `nil`, fail and throw an
7575
/// error.
@@ -124,10 +124,10 @@ public macro require(
124124
@freestanding(expression)
125125
@_documentation(visibility: private)
126126
public macro require<T>(
127-
_ optionalValue: T,
127+
_ optionalValue: consuming T,
128128
_ comment: @autoclosure () -> Comment? = nil,
129129
sourceLocation: SourceLocation = #_sourceLocation
130-
) -> T = #externalMacro(module: "TestingMacros", type: "NonOptionalRequireMacro")
130+
) -> T = #externalMacro(module: "TestingMacros", type: "NonOptionalRequireMacro") where T: ~Copyable
131131

132132
// MARK: - Matching errors by type
133133

0 commit comments

Comments
 (0)