Skip to content

Commit

Permalink
Get collection diffing working
Browse files Browse the repository at this point in the history
  • Loading branch information
grynspan committed Dec 4, 2024
1 parent 3965d35 commit 8c19484
Show file tree
Hide file tree
Showing 11 changed files with 363 additions and 71 deletions.
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
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ public func __checkCondition(
isRequired: Bool,
sourceLocation: SourceLocation
) rethrows -> Result<Void, any Error> {
var expectationContext = __ExpectationContext(sourceCode: sourceCode)
var expectationContext = __ExpectationContext.init(sourceCode: sourceCode)
let condition = try condition(&expectationContext)

return check(
Expand Down
211 changes: 203 additions & 8 deletions Sources/Testing/Expectations/ExpectationContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,20 @@ public struct __ExpectationContext: ~Copyable {
/// will not be assigned a runtime value.
var runtimeValues: [__ExpressionID: () -> Expression.Value?]

init(sourceCode: [__ExpressionID: String] = [:], runtimeValues: [__ExpressionID: () -> Expression.Value?] = [:]) {
/// Computed differences between the operands or arguments of expressions.
///
/// The values in this dictionary are gathered at runtime as subexpressions
/// are evaluated, much like ``runtimeValues``.
var differences: [__ExpressionID: () -> CollectionDifference<Any>?]

init(
sourceCode: [__ExpressionID: String] = [:],
runtimeValues: [__ExpressionID: () -> Expression.Value?] = [:],
differences: [__ExpressionID: () -> CollectionDifference<Any>?] = [:]
) {
self.sourceCode = sourceCode
self.runtimeValues = runtimeValues
self.differences = differences
}

/// Collapse the given expression graph into one or more expressions with
Expand Down Expand Up @@ -81,8 +92,8 @@ public struct __ExpectationContext: ~Copyable {
/// - Returns: An expression value representing the condition expression that
/// was evaluated.
///
/// This function should ideally be `consuming`, but because it is used in a
/// `lazy var` declaration, the compiler currently disallows it.
/// - Bug: This function should ideally be `consuming`, but because it is used
/// in a `lazy var` declaration, the compiler currently disallows it.
borrowing func finalize(successfully: Bool) -> __Expression {
// Construct a graph containing the source code for all the subexpressions
// we've captured during evaluation.
Expand All @@ -102,6 +113,15 @@ public struct __ExpectationContext: ~Copyable {
expressionGraph[keyPath] = expression
}
}

for (id, difference) in differences {
let keyPath = id.keyPath
if var expression = expressionGraph[keyPath], let difference = difference() {
let differenceDescription = Self._description(of: difference)
expression.differenceDescription = differenceDescription
expressionGraph[keyPath] = expression
}
}
}

// Flatten the expression graph.
Expand Down Expand Up @@ -154,11 +174,12 @@ extension __ExpectationContext {
///
/// - Warning: This function is used to implement the `#expect()` and
/// `#require()` macros. Do not call it directly.
public mutating func callAsFunction<T>(_ value: T, _ id: __ExpressionID) -> T where T: Copyable {
public mutating func callAsFunction<T>(_ value: T, _ id: __ExpressionID) -> T {
runtimeValues[id] = { Expression.Value(reflecting: value) }
return value
}

#if SWT_SUPPORTS_MOVE_ONLY_EXPRESSION_EXPANSION
/// Capture information about a value for use if the expectation currently
/// being evaluated fails.
///
Expand All @@ -176,7 +197,181 @@ extension __ExpectationContext {
// TODO: add support for borrowing non-copyable expressions (need @lifetime)
return value
}
#endif
}

// MARK: - Collection comparison and diffing

extension __ExpectationContext {
/// Convert an instance of `CollectionDifference` to one that is type-erased
/// over elements of type `Any`.
///
/// - Parameters:
/// - difference: The difference to convert.
///
/// - Returns: A type-erased copy of `difference`.
private static func _typeEraseCollectionDifference(_ difference: CollectionDifference<some Any>) -> CollectionDifference<Any> {
CollectionDifference<Any>(
difference.lazy.map { change in
switch change {
case let .insert(offset, element, associatedWith):
return .insert(offset: offset, element: element as Any, associatedWith: associatedWith)
case let .remove(offset, element, associatedWith):
return .remove(offset: offset, element: element as Any, associatedWith: associatedWith)
}
}
)!
}

/// Generate a description of a previously-computed collection difference.
///
/// - Parameters:
/// - difference: The difference to describe.
///
/// - Returns: A human-readable string describing `difference`.
private static func _description(of difference: CollectionDifference<some Any>) -> String {
let insertions: [String] = difference.insertions.lazy
.map(\.element)
.map(String.init(describingForTest:))
let removals: [String] = difference.removals.lazy
.map(\.element)
.map(String.init(describingForTest:))

var resultComponents = [String]()
if !insertions.isEmpty {
resultComponents.append("inserted [\(insertions.joined(separator: ", "))]")
}
if !removals.isEmpty {
resultComponents.append("removed [\(removals.joined(separator: ", "))]")
}

return resultComponents.joined(separator: ", ")
}

/// Compare two values using `==` or `!=`.
///
/// - Parameters:
/// - lhs: The left-hand operand.
/// - lhsID: A value that uniquely identifies the expression represented by
/// `lhs` in the context of the expectation currently being evaluated.
/// - rhs: The left-hand operand.
/// - rhsID: A value that uniquely identifies the expression represented by
/// `rhs` in the context of the expectation currently being evaluated.
/// - op: A function that performs an operation on `lhs` and `rhs`.
/// - opID: A value that uniquely identifies the expression represented by
/// `op` in the context of the expectation currently being evaluated.
///
/// - Returns: The result of calling `op(lhs, rhs)`.
///
/// This overload of `__cmp()` serves as a catch-all for operands that are not
/// collections or otherwise are not interesting to the testing library.
///
/// - Warning: This function is used to implement the `#expect()` and
/// `#require()` macros. Do not call it directly.
public mutating func __cmp<T, U, R>(
_ lhs: T,
_ lhsID: __ExpressionID,
_ rhs: U,
_ rhsID: __ExpressionID,
_ op: (T, U) throws -> R,
_ opID: __ExpressionID
) rethrows -> R {
try self(op(self(lhs, lhsID), self(rhs, rhsID)), opID)
}

/// Compare two bidirectional collections using `==` or `!=`.
///
/// This overload of `__cmp()` performs a diffing operation on `lhs` and `rhs`
/// if the result of `op(lhs, rhs)` is `false`.
///
/// - Warning: This function is used to implement the `#expect()` and
/// `#require()` macros. Do not call it directly.
public mutating func __cmp<C>(
_ lhs: C,
_ lhsID: __ExpressionID,
_ rhs: C,
_ rhsID: __ExpressionID,
_ op: (C, C) -> Bool,
_ opID: __ExpressionID
) -> Bool where C: BidirectionalCollection, C.Element: Equatable {
let result = self(op(self(lhs, lhsID), self(rhs, rhsID)), opID)

if !result {
differences[opID] = { [lhs, rhs] in
Self._typeEraseCollectionDifference(lhs.difference(from: rhs))
}
}

return result
}

/// Compare two range expressions using `==` or `!=`.
///
/// This overload of `__cmp()` does _not_ perform a diffing operation on `lhs`
/// and `rhs`. Range expressions are not usefully diffable the way other kinds
/// of collections are. ([139222774](rdar://139222774))
///
/// - Warning: This function is used to implement the `#expect()` and
/// `#require()` macros. Do not call it directly.
public mutating func __cmp<R>(
_ lhs: R,
_ lhsID: __ExpressionID,
_ rhs: R,
_ rhsID: __ExpressionID,
_ op: (R, R) -> Bool,
_ opID: __ExpressionID
) -> Bool where R: RangeExpression & BidirectionalCollection, R.Element: Equatable {
self(op(self(lhs, lhsID), self(rhs, rhsID)), opID)
}

/// Compare two strings using `==` or `!=`.
///
/// This overload of `__cmp()` performs a diffing operation on `lhs` and `rhs`
/// if the result of `op(lhs, rhs)` is `false`, but does so by _line_, not by
/// _character_.
///
/// - Warning: This function is used to implement the `#expect()` and
/// `#require()` macros. Do not call it directly.
public mutating func __cmp<S>(
_ lhs: S,
_ lhsID: __ExpressionID,
_ rhs: S,
_ rhsID: __ExpressionID,
_ op: (S, S) -> Bool,
_ opID: __ExpressionID
) -> Bool where S: StringProtocol {
let result = self(op(self(lhs, lhsID), self(rhs, rhsID)), opID)

if !result {
differences[opID] = { [lhs, rhs] in
// Compare strings by line, not by character.
let lhsLines = String(lhs).split(whereSeparator: \.isNewline)
let rhsLines = String(rhs).split(whereSeparator: \.isNewline)

if lhsLines.count == 1 && rhsLines.count == 1 {
// There are no newlines in either string, so there's no meaningful
// per-line difference. Bail.
return nil
}

let diff = lhsLines.difference(from: rhsLines)
if diff.isEmpty {
// The strings must have compared on a per-character basis, or this
// operator doesn't behave the way we expected. Bail.
return nil
}

return Self._typeEraseCollectionDifference(diff)
}
}

return result
}
}

// MARK: - Casting

extension __ExpectationContext {
/// Perform a conditional cast (`as?`) on a value.
///
/// - Parameters:
Expand Down Expand Up @@ -258,15 +453,15 @@ extension __ExpectationContext {
///
/// - Warning: This function is used to implement the `#expect()` and
/// `#require()` macros. Do not call it directly.
public mutating func callAsFunction<T, U>(_ value: T, _ id: __ExpressionID) -> U where T: StringProtocol, U: _Pointer {
public mutating func callAsFunction<P>(_ value: String, _ id: __ExpressionID) -> P where P: _Pointer {
// Perform the normal value capture.
let result = self(value, id)

// Create a C string copy of `value`.
#if os(Windows)
let resultCString = _strdup(String(result))!
let resultCString = _strdup(result)!
#else
let resultCString = strdup(String(result))!
let resultCString = strdup(result)!
#endif

// Store the C string pointer so we can free it later when this context is
Expand All @@ -277,7 +472,7 @@ extension __ExpectationContext {
_transformedCStrings.append(resultCString)

// Return the C string as whatever pointer type the caller wants.
return U(bitPattern: Int(bitPattern: resultCString)).unsafelyUnwrapped
return P(bitPattern: Int(bitPattern: resultCString)).unsafelyUnwrapped
}
}
#endif
12 changes: 12 additions & 0 deletions Sources/Testing/SourceAttribution/Expression.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,18 @@ public struct __Expression: Sendable {
@_spi(ForToolsIntegrationOnly)
public internal(set) var subexpressions = [Self]()

/// A description of the difference between the operands in this expression,
/// if that difference could be determined.
///
/// The value of this property is set for the binary operators `==` and `!=`
/// when used to compare collections.
///
/// If the containing expectation passed, the value of this property is `nil`
/// because the difference is only computed when necessary to assist with
/// diagnosing test failures.
@_spi(Experimental) @_spi(ForToolsIntegrationOnly)
public internal(set) var differenceDescription: String?

@_spi(ForToolsIntegrationOnly)
@available(*, deprecated, message: "The value of this property is always nil.")
public var stringLiteralValue: String? {
Expand Down
3 changes: 3 additions & 0 deletions Sources/Testing/SourceAttribution/ExpressionID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
/// A type providing unique identifiers for expressions captured during
/// expansion of the `#expect()` and `#require()` macros.
///
/// In the future, this type may use [`StaticBigInt`](https://developer.apple.com/documentation/swift/staticbigint)
/// as its source representation rather than a string literal.
///
/// - Warning: This type is used to implement the `#expect()` and `#require()`
/// macros. Do not use it directly.
public struct __ExpressionID: Sendable {
Expand Down
7 changes: 5 additions & 2 deletions Sources/TestingMacros/ConditionMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,11 @@ extension ConditionMacro {

checkArguments.append(Argument(expression: argumentExpr))

let sourceCodeNodeIDs = rewrittenNodes.compactMap { $0.expressionID(rootedAt: originalArgumentExpr) }
let sourceCodeExprs = rewrittenNodes.map { StringLiteralExprSyntax(content: $0.trimmedDescription) }
// Sort the rewritten nodes. This isn't strictly necessary for
// correctness but it does make the produced code more consistent.
let sortedRewrittenNodes = rewrittenNodes.sorted { $0.id < $1.id }
let sourceCodeNodeIDs = sortedRewrittenNodes.compactMap { $0.expressionID(rootedAt: originalArgumentExpr) }
let sourceCodeExprs = sortedRewrittenNodes.map { StringLiteralExprSyntax(content: $0.trimmedDescription) }
let sourceCodeExpr = DictionaryExprSyntax {
for (nodeID, sourceCodeExpr) in zip(sourceCodeNodeIDs, sourceCodeExprs) {
DictionaryElementSyntax(key: nodeID, value: sourceCodeExpr)
Expand Down
Loading

0 comments on commit 8c19484

Please sign in to comment.