Skip to content

Commit 8c19484

Browse files
committed
Get collection diffing working
1 parent 3965d35 commit 8c19484

11 files changed

+363
-71
lines changed

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
///

Sources/Testing/Expectations/ExpectationChecking+Macro.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ public func __checkCondition(
111111
isRequired: Bool,
112112
sourceLocation: SourceLocation
113113
) rethrows -> Result<Void, any Error> {
114-
var expectationContext = __ExpectationContext(sourceCode: sourceCode)
114+
var expectationContext = __ExpectationContext.init(sourceCode: sourceCode)
115115
let condition = try condition(&expectationContext)
116116

117117
return check(

Sources/Testing/Expectations/ExpectationContext.swift

+203-8
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,20 @@ public struct __ExpectationContext: ~Copyable {
3333
/// will not be assigned a runtime value.
3434
var runtimeValues: [__ExpressionID: () -> Expression.Value?]
3535

36-
init(sourceCode: [__ExpressionID: String] = [:], runtimeValues: [__ExpressionID: () -> Expression.Value?] = [:]) {
36+
/// Computed differences between the operands or arguments of expressions.
37+
///
38+
/// The values in this dictionary are gathered at runtime as subexpressions
39+
/// are evaluated, much like ``runtimeValues``.
40+
var differences: [__ExpressionID: () -> CollectionDifference<Any>?]
41+
42+
init(
43+
sourceCode: [__ExpressionID: String] = [:],
44+
runtimeValues: [__ExpressionID: () -> Expression.Value?] = [:],
45+
differences: [__ExpressionID: () -> CollectionDifference<Any>?] = [:]
46+
) {
3747
self.sourceCode = sourceCode
3848
self.runtimeValues = runtimeValues
49+
self.differences = differences
3950
}
4051

4152
/// Collapse the given expression graph into one or more expressions with
@@ -81,8 +92,8 @@ public struct __ExpectationContext: ~Copyable {
8192
/// - Returns: An expression value representing the condition expression that
8293
/// was evaluated.
8394
///
84-
/// This function should ideally be `consuming`, but because it is used in a
85-
/// `lazy var` declaration, the compiler currently disallows it.
95+
/// - Bug: This function should ideally be `consuming`, but because it is used
96+
/// in a `lazy var` declaration, the compiler currently disallows it.
8697
borrowing func finalize(successfully: Bool) -> __Expression {
8798
// Construct a graph containing the source code for all the subexpressions
8899
// we've captured during evaluation.
@@ -102,6 +113,15 @@ public struct __ExpectationContext: ~Copyable {
102113
expressionGraph[keyPath] = expression
103114
}
104115
}
116+
117+
for (id, difference) in differences {
118+
let keyPath = id.keyPath
119+
if var expression = expressionGraph[keyPath], let difference = difference() {
120+
let differenceDescription = Self._description(of: difference)
121+
expression.differenceDescription = differenceDescription
122+
expressionGraph[keyPath] = expression
123+
}
124+
}
105125
}
106126

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

182+
#if SWT_SUPPORTS_MOVE_ONLY_EXPRESSION_EXPANSION
162183
/// Capture information about a value for use if the expectation currently
163184
/// being evaluated fails.
164185
///
@@ -176,7 +197,181 @@ extension __ExpectationContext {
176197
// TODO: add support for borrowing non-copyable expressions (need @lifetime)
177198
return value
178199
}
200+
#endif
201+
}
202+
203+
// MARK: - Collection comparison and diffing
204+
205+
extension __ExpectationContext {
206+
/// Convert an instance of `CollectionDifference` to one that is type-erased
207+
/// over elements of type `Any`.
208+
///
209+
/// - Parameters:
210+
/// - difference: The difference to convert.
211+
///
212+
/// - Returns: A type-erased copy of `difference`.
213+
private static func _typeEraseCollectionDifference(_ difference: CollectionDifference<some Any>) -> CollectionDifference<Any> {
214+
CollectionDifference<Any>(
215+
difference.lazy.map { change in
216+
switch change {
217+
case let .insert(offset, element, associatedWith):
218+
return .insert(offset: offset, element: element as Any, associatedWith: associatedWith)
219+
case let .remove(offset, element, associatedWith):
220+
return .remove(offset: offset, element: element as Any, associatedWith: associatedWith)
221+
}
222+
}
223+
)!
224+
}
225+
226+
/// Generate a description of a previously-computed collection difference.
227+
///
228+
/// - Parameters:
229+
/// - difference: The difference to describe.
230+
///
231+
/// - Returns: A human-readable string describing `difference`.
232+
private static func _description(of difference: CollectionDifference<some Any>) -> String {
233+
let insertions: [String] = difference.insertions.lazy
234+
.map(\.element)
235+
.map(String.init(describingForTest:))
236+
let removals: [String] = difference.removals.lazy
237+
.map(\.element)
238+
.map(String.init(describingForTest:))
239+
240+
var resultComponents = [String]()
241+
if !insertions.isEmpty {
242+
resultComponents.append("inserted [\(insertions.joined(separator: ", "))]")
243+
}
244+
if !removals.isEmpty {
245+
resultComponents.append("removed [\(removals.joined(separator: ", "))]")
246+
}
247+
248+
return resultComponents.joined(separator: ", ")
249+
}
250+
251+
/// Compare two values using `==` or `!=`.
252+
///
253+
/// - Parameters:
254+
/// - lhs: The left-hand operand.
255+
/// - lhsID: A value that uniquely identifies the expression represented by
256+
/// `lhs` in the context of the expectation currently being evaluated.
257+
/// - rhs: The left-hand operand.
258+
/// - rhsID: A value that uniquely identifies the expression represented by
259+
/// `rhs` in the context of the expectation currently being evaluated.
260+
/// - op: A function that performs an operation on `lhs` and `rhs`.
261+
/// - opID: A value that uniquely identifies the expression represented by
262+
/// `op` in the context of the expectation currently being evaluated.
263+
///
264+
/// - Returns: The result of calling `op(lhs, rhs)`.
265+
///
266+
/// This overload of `__cmp()` serves as a catch-all for operands that are not
267+
/// collections or otherwise are not interesting to the testing library.
268+
///
269+
/// - Warning: This function is used to implement the `#expect()` and
270+
/// `#require()` macros. Do not call it directly.
271+
public mutating func __cmp<T, U, R>(
272+
_ lhs: T,
273+
_ lhsID: __ExpressionID,
274+
_ rhs: U,
275+
_ rhsID: __ExpressionID,
276+
_ op: (T, U) throws -> R,
277+
_ opID: __ExpressionID
278+
) rethrows -> R {
279+
try self(op(self(lhs, lhsID), self(rhs, rhsID)), opID)
280+
}
281+
282+
/// Compare two bidirectional collections using `==` or `!=`.
283+
///
284+
/// This overload of `__cmp()` performs a diffing operation on `lhs` and `rhs`
285+
/// if the result of `op(lhs, rhs)` is `false`.
286+
///
287+
/// - Warning: This function is used to implement the `#expect()` and
288+
/// `#require()` macros. Do not call it directly.
289+
public mutating func __cmp<C>(
290+
_ lhs: C,
291+
_ lhsID: __ExpressionID,
292+
_ rhs: C,
293+
_ rhsID: __ExpressionID,
294+
_ op: (C, C) -> Bool,
295+
_ opID: __ExpressionID
296+
) -> Bool where C: BidirectionalCollection, C.Element: Equatable {
297+
let result = self(op(self(lhs, lhsID), self(rhs, rhsID)), opID)
298+
299+
if !result {
300+
differences[opID] = { [lhs, rhs] in
301+
Self._typeEraseCollectionDifference(lhs.difference(from: rhs))
302+
}
303+
}
304+
305+
return result
306+
}
179307

308+
/// Compare two range expressions using `==` or `!=`.
309+
///
310+
/// This overload of `__cmp()` does _not_ perform a diffing operation on `lhs`
311+
/// and `rhs`. Range expressions are not usefully diffable the way other kinds
312+
/// of collections are. ([139222774](rdar://139222774))
313+
///
314+
/// - Warning: This function is used to implement the `#expect()` and
315+
/// `#require()` macros. Do not call it directly.
316+
public mutating func __cmp<R>(
317+
_ lhs: R,
318+
_ lhsID: __ExpressionID,
319+
_ rhs: R,
320+
_ rhsID: __ExpressionID,
321+
_ op: (R, R) -> Bool,
322+
_ opID: __ExpressionID
323+
) -> Bool where R: RangeExpression & BidirectionalCollection, R.Element: Equatable {
324+
self(op(self(lhs, lhsID), self(rhs, rhsID)), opID)
325+
}
326+
327+
/// Compare two strings using `==` or `!=`.
328+
///
329+
/// This overload of `__cmp()` performs a diffing operation on `lhs` and `rhs`
330+
/// if the result of `op(lhs, rhs)` is `false`, but does so by _line_, not by
331+
/// _character_.
332+
///
333+
/// - Warning: This function is used to implement the `#expect()` and
334+
/// `#require()` macros. Do not call it directly.
335+
public mutating func __cmp<S>(
336+
_ lhs: S,
337+
_ lhsID: __ExpressionID,
338+
_ rhs: S,
339+
_ rhsID: __ExpressionID,
340+
_ op: (S, S) -> Bool,
341+
_ opID: __ExpressionID
342+
) -> Bool where S: StringProtocol {
343+
let result = self(op(self(lhs, lhsID), self(rhs, rhsID)), opID)
344+
345+
if !result {
346+
differences[opID] = { [lhs, rhs] in
347+
// Compare strings by line, not by character.
348+
let lhsLines = String(lhs).split(whereSeparator: \.isNewline)
349+
let rhsLines = String(rhs).split(whereSeparator: \.isNewline)
350+
351+
if lhsLines.count == 1 && rhsLines.count == 1 {
352+
// There are no newlines in either string, so there's no meaningful
353+
// per-line difference. Bail.
354+
return nil
355+
}
356+
357+
let diff = lhsLines.difference(from: rhsLines)
358+
if diff.isEmpty {
359+
// The strings must have compared on a per-character basis, or this
360+
// operator doesn't behave the way we expected. Bail.
361+
return nil
362+
}
363+
364+
return Self._typeEraseCollectionDifference(diff)
365+
}
366+
}
367+
368+
return result
369+
}
370+
}
371+
372+
// MARK: - Casting
373+
374+
extension __ExpectationContext {
180375
/// Perform a conditional cast (`as?`) on a value.
181376
///
182377
/// - Parameters:
@@ -258,15 +453,15 @@ extension __ExpectationContext {
258453
///
259454
/// - Warning: This function is used to implement the `#expect()` and
260455
/// `#require()` macros. Do not call it directly.
261-
public mutating func callAsFunction<T, U>(_ value: T, _ id: __ExpressionID) -> U where T: StringProtocol, U: _Pointer {
456+
public mutating func callAsFunction<P>(_ value: String, _ id: __ExpressionID) -> P where P: _Pointer {
262457
// Perform the normal value capture.
263458
let result = self(value, id)
264459

265460
// Create a C string copy of `value`.
266461
#if os(Windows)
267-
let resultCString = _strdup(String(result))!
462+
let resultCString = _strdup(result)!
268463
#else
269-
let resultCString = strdup(String(result))!
464+
let resultCString = strdup(result)!
270465
#endif
271466

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

279474
// Return the C string as whatever pointer type the caller wants.
280-
return U(bitPattern: Int(bitPattern: resultCString)).unsafelyUnwrapped
475+
return P(bitPattern: Int(bitPattern: resultCString)).unsafelyUnwrapped
281476
}
282477
}
283478
#endif

Sources/Testing/SourceAttribution/Expression.swift

+12
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,18 @@ public struct __Expression: Sendable {
240240
@_spi(ForToolsIntegrationOnly)
241241
public internal(set) var subexpressions = [Self]()
242242

243+
/// A description of the difference between the operands in this expression,
244+
/// if that difference could be determined.
245+
///
246+
/// The value of this property is set for the binary operators `==` and `!=`
247+
/// when used to compare collections.
248+
///
249+
/// If the containing expectation passed, the value of this property is `nil`
250+
/// because the difference is only computed when necessary to assist with
251+
/// diagnosing test failures.
252+
@_spi(Experimental) @_spi(ForToolsIntegrationOnly)
253+
public internal(set) var differenceDescription: String?
254+
243255
@_spi(ForToolsIntegrationOnly)
244256
@available(*, deprecated, message: "The value of this property is always nil.")
245257
public var stringLiteralValue: String? {

Sources/Testing/SourceAttribution/ExpressionID.swift

+3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
/// A type providing unique identifiers for expressions captured during
1212
/// expansion of the `#expect()` and `#require()` macros.
1313
///
14+
/// In the future, this type may use [`StaticBigInt`](https://developer.apple.com/documentation/swift/staticbigint)
15+
/// as its source representation rather than a string literal.
16+
///
1417
/// - Warning: This type is used to implement the `#expect()` and `#require()`
1518
/// macros. Do not use it directly.
1619
public struct __ExpressionID: Sendable {

Sources/TestingMacros/ConditionMacro.swift

+5-2
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,11 @@ extension ConditionMacro {
198198

199199
checkArguments.append(Argument(expression: argumentExpr))
200200

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

0 commit comments

Comments
 (0)