Skip to content

Commit 47f1b1a

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 65ca016 commit 47f1b1a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+2669
-1740
lines changed

Package.swift

+8
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ let package = Package(
9191
],
9292
swiftSettings: .packageSettings
9393
),
94+
.testTarget(
95+
name: "SubexpressionShowcase",
96+
dependencies: [
97+
"Testing",
98+
],
99+
swiftSettings: .packageSettings
100+
),
94101

95102
.macro(
96103
name: "TestingMacros",
@@ -198,6 +205,7 @@ extension Array where Element == PackageDescription.SwiftSetting {
198205
result += [
199206
.enableUpcomingFeature("ExistentialAny"),
200207
.enableExperimentalFeature("SuppressedAssociatedTypes"),
208+
.enableExperimentalFeature("NonescapableTypes"),
201209

202210
.enableExperimentalFeature("AccessLevelOnImport"),
203211
.enableUpcomingFeature("InternalImportsByDefault"),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
extension ABI {
12+
/// A type implementing the JSON encoding of ``Expectation`` for the ABI entry
13+
/// point and event stream output.
14+
///
15+
/// This type is not part of the public interface of the testing library. It
16+
/// assists in converting values to JSON; clients that consume this JSON are
17+
/// expected to write their own decoders.
18+
///
19+
/// - Warning: Expectations are not yet part of the JSON schema.
20+
struct EncodedExpectation<V>: Sendable where V: ABI.Version {
21+
/// The expression evaluated by this expectation.
22+
///
23+
/// - Warning: Expressions are not yet part of the JSON schema.
24+
var _expression: EncodedExpression<V>
25+
26+
init(encoding expectation: borrowing Expectation, in eventContext: borrowing Event.Context) {
27+
_expression = EncodedExpression<V>(encoding: expectation.evaluatedExpression, in: eventContext)
28+
}
29+
}
30+
}
31+
32+
// MARK: - Codable
33+
34+
extension ABI.EncodedExpectation: Codable {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
extension ABI {
12+
/// A type implementing the JSON encoding of ``Expression`` for the ABI entry
13+
/// point and event stream output.
14+
///
15+
/// This type is not part of the public interface of the testing library. It
16+
/// assists in converting values to JSON; clients that consume this JSON are
17+
/// expected to write their own decoders.
18+
///
19+
/// - Warning: Expressions are not yet part of the JSON schema.
20+
struct EncodedExpression<V>: Sendable where V: ABI.Version {
21+
/// The source code of the original captured expression.
22+
var sourceCode: String
23+
24+
/// A string representation of the runtime value of this expression.
25+
///
26+
/// If the runtime value of this expression has not been evaluated, the
27+
/// value of this property is `nil`.
28+
var runtimeValue: String?
29+
30+
/// The fully-qualified name of the type of value represented by
31+
/// `runtimeValue`, or `nil` if that value has not been captured.
32+
var runtimeTypeName: String?
33+
34+
/// Any child expressions within this expression.
35+
var children: [EncodedExpression]?
36+
37+
init(encoding expression: borrowing __Expression, in eventContext: borrowing Event.Context) {
38+
sourceCode = expression.sourceCode
39+
runtimeValue = expression.runtimeValue.map(String.init(describingForTest:))
40+
runtimeTypeName = expression.runtimeValue.map(\.typeInfo.fullyQualifiedName)
41+
if !expression.subexpressions.isEmpty {
42+
children = expression.subexpressions.map { [eventContext = copy eventContext] subexpression in
43+
Self(encoding: subexpression, in: eventContext)
44+
}
45+
}
46+
}
47+
}
48+
}
49+
50+
// MARK: - Codable
51+
52+
extension ABI.EncodedExpression: Codable {}

Sources/Testing/ABI/Encoded/ABI.EncodedIssue.swift

+8
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ extension ABI {
4545
/// - Warning: Errors are not yet part of the JSON schema.
4646
var _error: EncodedError<V>?
4747

48+
/// The expectation associated with this issue, if applicable.
49+
///
50+
/// - Warning: Expectations are not yet part of the JSON schema.
51+
var _expectation: EncodedExpectation<V>?
52+
4853
init(encoding issue: borrowing Issue, in eventContext: borrowing Event.Context) {
4954
_severity = switch issue.severity {
5055
case .warning: .warning
@@ -58,6 +63,9 @@ extension ABI {
5863
if let error = issue.error {
5964
_error = EncodedError(encoding: error, in: eventContext)
6065
}
66+
if case let .expectationFailed(expectation) = issue.kind {
67+
_expectation = EncodedExpectation(encoding: expectation, in: eventContext)
68+
}
6169
}
6270
}
6371
}

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

+8
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,19 @@ extension ABI {
6464
/// The symbol associated with this message.
6565
var symbol: Symbol
6666

67+
/// How much to indent this message when presenting it.
68+
///
69+
/// - Warning: This property is not yet part of the JSON schema.
70+
var _indentation: Int?
71+
6772
/// The human-readable, unformatted text associated with this message.
6873
var text: String
6974

7075
init(encoding message: borrowing Event.HumanReadableOutputRecorder.Message) {
7176
symbol = Symbol(encoding: message.symbol ?? .default)
77+
if message.indentation > 0 {
78+
_indentation = message.indentation
79+
}
7280
text = message.conciseStringValue ?? message.stringValue
7381
}
7482
}

Sources/Testing/CMakeLists.txt

+5-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ add_library(Testing
1717
ABI/Encoded/ABI.EncodedBacktrace.swift
1818
ABI/Encoded/ABI.EncodedError.swift
1919
ABI/Encoded/ABI.EncodedEvent.swift
20+
ABI/Encoded/ABI.EncodedExpectation.swift
21+
ABI/Encoded/ABI.EncodedExpression.swift
2022
ABI/Encoded/ABI.EncodedInstant.swift
2123
ABI/Encoded/ABI.EncodedIssue.swift
2224
ABI/Encoded/ABI.EncodedMessage.swift
@@ -40,6 +42,8 @@ add_library(Testing
4042
Expectations/Expectation.swift
4143
Expectations/Expectation+Macro.swift
4244
Expectations/ExpectationChecking+Macro.swift
45+
Expectations/ExpectationContext.swift
46+
Expectations/ExpectationContext+Pointers.swift
4347
Issues/Confirmation.swift
4448
Issues/ErrorSnapshot.swift
4549
Issues/Issue.swift
@@ -62,7 +66,7 @@ add_library(Testing
6266
SourceAttribution/Backtrace+Symbolication.swift
6367
SourceAttribution/CustomTestStringConvertible.swift
6468
SourceAttribution/Expression.swift
65-
SourceAttribution/Expression+Macro.swift
69+
SourceAttribution/ExpressionID.swift
6670
SourceAttribution/SourceContext.swift
6771
SourceAttribution/SourceLocation.swift
6872
SourceAttribution/SourceLocation+Macro.swift

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

+25-15
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,22 @@ private let _ansiEscapeCodePrefix = "\u{001B}["
129129
private let _resetANSIEscapeCode = "\(_ansiEscapeCodePrefix)0m"
130130

131131
extension Event.Symbol {
132+
/// Get the string value to use for a message with no associated symbol.
133+
///
134+
/// - Parameters:
135+
/// - options: Options to use when writing the symbol.
136+
///
137+
/// - Returns: A string representation of "no symbol" appropriate for writing
138+
/// to a stream.
139+
fileprivate static func placeholderStringValue(options: Event.ConsoleOutputRecorder.Options) -> String {
140+
#if os(macOS) || (os(iOS) && targetEnvironment(macCatalyst))
141+
if options.useSFSymbols {
142+
return " "
143+
}
144+
#endif
145+
return " "
146+
}
147+
132148
/// Get the string value for this symbol with the given write options.
133149
///
134150
/// - Parameters:
@@ -171,7 +187,7 @@ extension Event.Symbol {
171187
case .attachment:
172188
return "\(_ansiEscapeCodePrefix)94m\(symbolCharacter)\(_resetANSIEscapeCode)"
173189
case .details:
174-
return symbolCharacter
190+
return "\(symbolCharacter)"
175191
}
176192
}
177193
return "\(symbolCharacter)"
@@ -305,18 +321,12 @@ extension Event.ConsoleOutputRecorder {
305321
/// - Returns: Whether any output was produced and written to this instance's
306322
/// destination.
307323
@discardableResult public func record(_ event: borrowing Event, in context: borrowing Event.Context) -> Bool {
308-
let messages = _humanReadableOutputRecorder.record(event, in: context)
309-
310-
// Padding to use in place of a symbol for messages that don't have one.
311-
var padding = " "
312-
#if os(macOS) || (os(iOS) && targetEnvironment(macCatalyst))
313-
if options.useSFSymbols {
314-
padding = " "
315-
}
316-
#endif
324+
let symbolPlaceholder = Event.Symbol.placeholderStringValue(options: options)
317325

326+
let messages = _humanReadableOutputRecorder.record(event, in: context)
318327
let lines = messages.lazy.map { [test = context.test] message in
319-
let symbol = message.symbol?.stringValue(options: options) ?? padding
328+
let symbol = message.symbol?.stringValue(options: options) ?? symbolPlaceholder
329+
let indentation = String(repeating: " ", count: message.indentation)
320330

321331
if case .details = message.symbol {
322332
// Special-case the detail symbol to apply grey to the entire line of
@@ -325,17 +335,17 @@ extension Event.ConsoleOutputRecorder {
325335
// to the indentation provided by the symbol.
326336
var lines = message.stringValue.split(whereSeparator: \.isNewline)
327337
lines = CollectionOfOne(lines[0]) + lines.dropFirst().map { line in
328-
"\(padding) \(line)"
338+
"\(indentation)\(symbolPlaceholder) \(line)"
329339
}
330340
let stringValue = lines.joined(separator: "\n")
331341
if options.useANSIEscapeCodes, options.ansiColorBitDepth > 1 {
332-
return "\(_ansiEscapeCodePrefix)90m\(symbol) \(stringValue)\(_resetANSIEscapeCode)\n"
342+
return "\(_ansiEscapeCodePrefix)90m\(symbol) \(indentation)\(stringValue)\(_resetANSIEscapeCode)\n"
333343
} else {
334-
return "\(symbol) \(stringValue)\n"
344+
return "\(symbol) \(indentation)\(stringValue)\n"
335345
}
336346
} else {
337347
let colorDots = test.map { self.colorDots(for: $0.tags) } ?? ""
338-
return "\(symbol) \(colorDots)\(message.stringValue)\n"
348+
return "\(symbol) \(indentation)\(colorDots)\(message.stringValue)\n"
339349
}
340350
}
341351

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

+18-11
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ extension Event {
2525
/// The symbol associated with this message, if any.
2626
var symbol: Symbol?
2727

28+
/// How much to indent this message when presenting it.
29+
///
30+
/// The way in which this additional indentation is rendered is
31+
/// implementation-defined. Typically, the greater the value of this
32+
/// property, the more whitespace characters are inserted.
33+
///
34+
/// Rendering of indentation is optional.
35+
var indentation = 0
36+
2837
/// The human-readable message.
2938
var stringValue: String
3039

@@ -450,20 +459,18 @@ extension Event.HumanReadableOutputRecorder {
450459
}
451460
additionalMessages += _formattedComments(issue.comments)
452461

453-
if verbosity > 0, case let .expectationFailed(expectation) = issue.kind {
462+
if verbosity >= 0, case let .expectationFailed(expectation) = issue.kind {
454463
let expression = expectation.evaluatedExpression
455-
func addMessage(about expression: __Expression) {
456-
let description = expression.expandedDebugDescription()
457-
additionalMessages.append(Message(symbol: .details, stringValue: description))
458-
}
459-
let subexpressions = expression.subexpressions
460-
if subexpressions.isEmpty {
461-
addMessage(about: expression)
462-
} else {
463-
for subexpression in subexpressions {
464-
addMessage(about: subexpression)
464+
func addMessage(about expression: __Expression, depth: Int) {
465+
let description = expression.expandedDescription(verbose: verbosity > 0)
466+
if description != expression.sourceCode {
467+
additionalMessages.append(Message(symbol: .details, indentation: depth, stringValue: description))
468+
}
469+
for subexpression in expression.subexpressions {
470+
addMessage(about: subexpression, depth: depth + 1)
465471
}
466472
}
473+
addMessage(about: expression, depth: 0)
467474
}
468475

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

Sources/Testing/ExitTests/ExitTest.swift

+8-4
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ func callExitTest(
337337
identifiedBy exitTestID: (UInt64, UInt64),
338338
exitsWith expectedExitCondition: ExitTest.Condition,
339339
observing observedValues: [any PartialKeyPath<ExitTest.Result> & Sendable],
340-
expression: __Expression,
340+
sourceCode: @escaping @autoclosure @Sendable () -> [__ExpressionID: String],
341341
comments: @autoclosure () -> [Comment],
342342
isRequired: Bool,
343343
isolation: isolated (any Actor)? = #isolation,
@@ -394,10 +394,14 @@ func callExitTest(
394394
let actualStatusAtExit = result.statusAtExit
395395

396396
// Plumb the exit test's result through the general expectation machinery.
397-
return __checkValue(
397+
let expectationContext = __ExpectationContext(
398+
sourceCode: sourceCode(),
399+
runtimeValues: [.root: { Expression.Value(reflecting: actualStatusAtExit) }]
400+
)
401+
return check(
398402
expectedExitCondition.isApproximatelyEqual(to: actualStatusAtExit),
399-
expression: expression,
400-
expressionWithCapturedRuntimeValues: expression.capturingRuntimeValues(actualStatusAtExit),
403+
expectationContext: expectationContext,
404+
mismatchedErrorDescription: nil,
401405
mismatchedExitConditionDescription: String(describingForTest: expectedExitCondition),
402406
comments: comments(),
403407
isRequired: isRequired,

Sources/Testing/Expectations/Expectation+Macro.swift

+4-4
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: "UnwrapMacro") 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

Sources/Testing/Expectations/Expectation.swift

+5-3
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@
1212
public struct Expectation: Sendable {
1313
/// The expression evaluated by this expectation.
1414
@_spi(ForToolsIntegrationOnly)
15-
public var evaluatedExpression: Expression
15+
public internal(set) var evaluatedExpression: Expression
1616

1717
/// A description of the error mismatch that occurred, if any.
1818
///
1919
/// If this expectation passed, the value of this property is `nil` because no
2020
/// error mismatch occurred.
2121
@_spi(Experimental) @_spi(ForToolsIntegrationOnly)
22-
public var mismatchedErrorDescription: String?
22+
public internal(set) var mismatchedErrorDescription: String?
2323

2424
/// A description of the difference between the operands in the expression
2525
/// evaluated by this expectation, if the difference could be determined.
@@ -28,7 +28,9 @@ public struct Expectation: Sendable {
2828
/// the difference is only computed when necessary to assist with diagnosing
2929
/// test failures.
3030
@_spi(Experimental) @_spi(ForToolsIntegrationOnly)
31-
public var differenceDescription: String?
31+
public var differenceDescription: String? {
32+
evaluatedExpression.differenceDescription
33+
}
3234

3335
/// A description of the exit condition that was expected to be matched.
3436
///

0 commit comments

Comments
 (0)