Skip to content

Commit

Permalink
Rethink how we capture expectation conditions and their subexpressions.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
grynspan committed Jan 24, 2025
1 parent faaabba commit 8db5bea
Show file tree
Hide file tree
Showing 39 changed files with 2,578 additions and 1,862 deletions.
7 changes: 7 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ let package = Package(
.enableExperimentalFeature("SymbolLinkageMarkers"),
]
),
.testTarget(
name: "SubexpressionShowcase",
dependencies: [
"Testing",
],
swiftSettings: .packageSettings
),

.macro(
name: "TestingMacros",
Expand Down
8 changes: 8 additions & 0 deletions Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,19 @@ extension ABIv0 {
/// The symbol associated with this message.
var symbol: Symbol

/// How much to indent this message when presenting it.
///
/// - Warning: This property is not yet part of the JSON schema.
var _indentation: Int?

/// The human-readable, unformatted text associated with this message.
var text: String

init(encoding message: borrowing Event.HumanReadableOutputRecorder.Message) {
symbol = Symbol(encoding: message.symbol ?? .default)
if message.indentation > 0 {
_indentation = message.indentation
}
text = message.conciseStringValue ?? message.stringValue
}
}
Expand Down
4 changes: 3 additions & 1 deletion Sources/Testing/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ add_library(Testing
Expectations/Expectation.swift
Expectations/Expectation+Macro.swift
Expectations/ExpectationChecking+Macro.swift
Expectations/ExpectationContext.swift
Expectations/ExpectationContext+Pointers.swift
Issues/Confirmation.swift
Issues/ErrorSnapshot.swift
Issues/Issue.swift
Expand All @@ -61,7 +63,7 @@ add_library(Testing
SourceAttribution/Backtrace+Symbolication.swift
SourceAttribution/CustomTestStringConvertible.swift
SourceAttribution/Expression.swift
SourceAttribution/Expression+Macro.swift
SourceAttribution/ExpressionID.swift
SourceAttribution/SourceContext.swift
SourceAttribution/SourceLocation.swift
SourceAttribution/SourceLocation+Macro.swift
Expand Down
40 changes: 25 additions & 15 deletions Sources/Testing/Events/Recorder/Event.ConsoleOutputRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,22 @@ private let _ansiEscapeCodePrefix = "\u{001B}["
private let _resetANSIEscapeCode = "\(_ansiEscapeCodePrefix)0m"

extension Event.Symbol {
/// Get the string value to use for a message with no associated symbol.
///
/// - Parameters:
/// - options: Options to use when writing the symbol.
///
/// - Returns: A string representation of "no symbol" appropriate for writing
/// to a stream.
fileprivate static func placeholderStringValue(options: Event.ConsoleOutputRecorder.Options) -> String {
#if os(macOS) || (os(iOS) && targetEnvironment(macCatalyst))
if options.useSFSymbols {
return " "
}
#endif
return " "
}

/// Get the string value for this symbol with the given write options.
///
/// - Parameters:
Expand Down Expand Up @@ -169,7 +185,7 @@ extension Event.Symbol {
case .attachment:
return "\(_ansiEscapeCodePrefix)94m\(symbolCharacter)\(_resetANSIEscapeCode)"
case .details:
return symbolCharacter
return "\(symbolCharacter)"
}
}
return "\(symbolCharacter)"
Expand Down Expand Up @@ -303,18 +319,12 @@ extension Event.ConsoleOutputRecorder {
/// - Returns: Whether any output was produced and written to this instance's
/// destination.
@discardableResult public func record(_ event: borrowing Event, in context: borrowing Event.Context) -> Bool {
let messages = _humanReadableOutputRecorder.record(event, in: context)

// Padding to use in place of a symbol for messages that don't have one.
var padding = " "
#if os(macOS) || (os(iOS) && targetEnvironment(macCatalyst))
if options.useSFSymbols {
padding = " "
}
#endif
let symbolPlaceholder = Event.Symbol.placeholderStringValue(options: options)

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

if case .details = message.symbol {
// Special-case the detail symbol to apply grey to the entire line of
Expand All @@ -323,17 +333,17 @@ extension Event.ConsoleOutputRecorder {
// to the indentation provided by the symbol.
var lines = message.stringValue.split(whereSeparator: \.isNewline)
lines = CollectionOfOne(lines[0]) + lines.dropFirst().map { line in
"\(padding) \(line)"
"\(indentation)\(symbolPlaceholder) \(line)"
}
let stringValue = lines.joined(separator: "\n")
if options.useANSIEscapeCodes, options.ansiColorBitDepth > 1 {
return "\(_ansiEscapeCodePrefix)90m\(symbol) \(stringValue)\(_resetANSIEscapeCode)\n"
return "\(_ansiEscapeCodePrefix)90m\(symbol) \(indentation)\(stringValue)\(_resetANSIEscapeCode)\n"
} else {
return "\(symbol) \(stringValue)\n"
return "\(symbol) \(indentation)\(stringValue)\n"
}
} else {
let colorDots = test.map { self.colorDots(for: $0.tags) } ?? ""
return "\(symbol) \(colorDots)\(message.stringValue)\n"
return "\(symbol) \(indentation)\(colorDots)\(message.stringValue)\n"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ extension Event {
/// The symbol associated with this message, if any.
var symbol: Symbol?

/// How much to indent this message when presenting it.
///
/// The way in which this additional indentation is rendered is
/// implementation-defined. Typically, the greater the value of this
/// property, the more whitespace characters are inserted.
///
/// Rendering of indentation is optional.
var indentation = 0

/// The human-readable message.
var stringValue: String

Expand Down Expand Up @@ -415,20 +424,18 @@ extension Event.HumanReadableOutputRecorder {
}
additionalMessages += _formattedComments(issue.comments)

if verbosity > 0, case let .expectationFailed(expectation) = issue.kind {
if verbosity >= 0, case let .expectationFailed(expectation) = issue.kind {
let expression = expectation.evaluatedExpression
func addMessage(about expression: __Expression) {
let description = expression.expandedDebugDescription()
additionalMessages.append(Message(symbol: .details, stringValue: description))
}
let subexpressions = expression.subexpressions
if subexpressions.isEmpty {
addMessage(about: expression)
} else {
for subexpression in subexpressions {
addMessage(about: subexpression)
func addMessage(about expression: __Expression, depth: Int) {
let description = expression.expandedDescription(verbose: verbosity > 0)
if description != expression.sourceCode {
additionalMessages.append(Message(symbol: .details, indentation: depth, stringValue: description))
}
for subexpression in expression.subexpressions {
addMessage(about: subexpression, depth: depth + 1)
}
}
addMessage(about: expression, depth: 0)
}

let atSourceLocation = issue.sourceLocation.map { " at \($0)" } ?? ""
Expand Down
12 changes: 8 additions & 4 deletions Sources/Testing/ExitTests/ExitTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ func callExitTest(
identifiedBy exitTestID: ExitTest.ID,
exitsWith expectedExitCondition: ExitCondition,
observing observedValues: [any PartialKeyPath<ExitTestArtifacts> & Sendable],
expression: __Expression,
sourceCode: @escaping @autoclosure @Sendable () -> [__ExpressionID: String],
comments: @autoclosure () -> [Comment],
isRequired: Bool,
isolation: isolated (any Actor)? = #isolation,
Expand Down Expand Up @@ -335,10 +335,14 @@ func callExitTest(
let actualExitCondition = result.exitCondition

// Plumb the exit test's result through the general expectation machinery.
return __checkValue(
let expectationContext = __ExpectationContext(
sourceCode: sourceCode(),
runtimeValues: [.root: { Expression.Value(reflecting: actualExitCondition) }]
)
return check(
expectedExitCondition == actualExitCondition,
expression: expression,
expressionWithCapturedRuntimeValues: expression.capturingRuntimeValues(actualExitCondition),
expectationContext: expectationContext,
mismatchedErrorDescription: nil,
mismatchedExitConditionDescription: String(describingForTest: expectedExitCondition),
comments: comments(),
isRequired: isRequired,
Expand Down
8 changes: 4 additions & 4 deletions Sources/Testing/Expectations/Expectation+Macro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@
/// running in the current task and an instance of ``ExpectationFailedError`` is
/// thrown.
@freestanding(expression) public macro require<T>(
_ optionalValue: T?,
_ optionalValue: consuming T?,
_ comment: @autoclosure () -> Comment? = nil,
sourceLocation: SourceLocation = #_sourceLocation
) -> T = #externalMacro(module: "TestingMacros", type: "RequireMacro")
) -> T = #externalMacro(module: "TestingMacros", type: "UnwrapMacro") where T: ~Copyable

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

// MARK: - Matching errors by type

Expand Down
8 changes: 5 additions & 3 deletions Sources/Testing/Expectations/Expectation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@
public struct Expectation: Sendable {
/// The expression evaluated by this expectation.
@_spi(ForToolsIntegrationOnly)
public var evaluatedExpression: Expression
public internal(set) var evaluatedExpression: Expression

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

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

/// A description of the exit condition that was expected to be matched.
///
Expand Down
Loading

0 comments on commit 8db5bea

Please sign in to comment.