diff --git a/Sources/Nimble/Adapters/NMBExpectation.swift b/Sources/Nimble/Adapters/NMBExpectation.swift index 44ced656..bef24841 100644 --- a/Sources/Nimble/Adapters/NMBExpectation.swift +++ b/Sources/Nimble/Adapters/NMBExpectation.swift @@ -13,18 +13,27 @@ private func from(objcMatcher: NMBMatcher) -> Matcher { } // Equivalent to Expectation, but for Nimble's Objective-C interface -public class NMBExpectation: NSObject { - internal let _actualBlock: () -> NSObject? - internal var _negative: Bool +public final class NMBExpectation: NSObject, Sendable { + internal let _actualBlock: @Sendable () -> NSObject? + internal let _negative: Bool internal let _file: FileString internal let _line: UInt - internal var _timeout: NimbleTimeInterval = .seconds(1) + internal let _timeout: NimbleTimeInterval - @objc public init(actualBlock: @escaping () -> NSObject?, negative: Bool, file: FileString, line: UInt) { + @objc public init(actualBlock: @escaping @Sendable () -> sending NSObject?, negative: Bool, file: FileString, line: UInt) { self._actualBlock = actualBlock self._negative = negative self._file = file self._line = line + self._timeout = .seconds(1) + } + + private init(actualBlock: @escaping @Sendable () -> sending NSObject?, negative: Bool, file: FileString, line: UInt, timeout: NimbleTimeInterval) { + self._actualBlock = actualBlock + self._negative = negative + self._file = file + self._line = line + self._timeout = timeout } private var expectValue: SyncExpectation { @@ -32,8 +41,14 @@ public class NMBExpectation: NSObject { } @objc public var withTimeout: (TimeInterval) -> NMBExpectation { - return { timeout in self._timeout = timeout.nimbleInterval - return self + return { timeout in + NMBExpectation( + actualBlock: self._actualBlock, + negative: self._negative, + file: self._file, + line: self._line, + timeout: timeout.nimbleInterval + ) } } diff --git a/Sources/Nimble/AsyncExpression.swift b/Sources/Nimble/AsyncExpression.swift index 0690c6b7..b752f331 100644 --- a/Sources/Nimble/AsyncExpression.swift +++ b/Sources/Nimble/AsyncExpression.swift @@ -1,5 +1,5 @@ /// Memoizes the given closure, only calling the passed closure once; even if repeat calls to the returned closure -private final class MemoizedClosure: Sendable { +private final class MemoizedClosure: Sendable { enum State { case notStarted case inProgress @@ -11,9 +11,9 @@ private final class MemoizedClosure: Sendable { nonisolated(unsafe) private var _continuations = [CheckedContinuation]() nonisolated(unsafe) private var _task: Task? - nonisolated(unsafe) let closure: () async throws -> sending T + let closure: @Sendable () async throws -> T - init(_ closure: @escaping () async throws -> sending T) { + init(_ closure: @escaping @Sendable () async throws -> T) { self.closure = closure } @@ -21,7 +21,7 @@ private final class MemoizedClosure: Sendable { _task?.cancel() } - @Sendable func callAsFunction(_ withoutCaching: Bool) async throws -> sending T { + @Sendable func callAsFunction(_ withoutCaching: Bool) async throws -> T { if withoutCaching { try await closure() } else { @@ -64,7 +64,9 @@ private final class MemoizedClosure: Sendable { // Memoizes the given closure, only calling the passed // closure once; even if repeat calls to the returned closure -private func memoizedClosure(_ closure: sending @escaping () async throws -> sending T) -> @Sendable (Bool) async throws -> sending T { +private func memoizedClosure( + _ closure: sending @escaping @Sendable () async throws -> T +) -> @Sendable (Bool) async throws -> T { let memoized = MemoizedClosure(closure) return memoized.callAsFunction(_:) } @@ -80,7 +82,7 @@ private func memoizedClosure(_ closure: sending @escaping () async throws -> /// /// This provides a common consumable API for matchers to utilize to allow /// Nimble to change internals to how the captured closure is managed. -public struct AsyncExpression { +public actor AsyncExpression { internal let _expression: @Sendable (Bool) async throws -> sending Value? internal let _withoutCaching: Bool public let location: SourceLocation diff --git a/Sources/Nimble/DSL+AsyncAwait.swift b/Sources/Nimble/DSL+AsyncAwait.swift index 38bee80b..44a4757e 100644 --- a/Sources/Nimble/DSL+AsyncAwait.swift +++ b/Sources/Nimble/DSL+AsyncAwait.swift @@ -87,8 +87,8 @@ public func expecta(file: FileString = #file, line: UInt = #line, _ expression: /// /// @warning /// Unlike the synchronous version of this call, this does not support catching Objective-C exceptions. -public func waitUntil(timeout: NimbleTimeInterval = PollingDefaults.timeout, file: FileString = #file, line: UInt = #line, action: sending @escaping (@escaping @Sendable () -> Void) async -> Void) async { - await throwableUntil(timeout: timeout) { done in +public func waitUntil(timeout: NimbleTimeInterval = PollingDefaults.timeout, file: FileString = #file, line: UInt = #line, action: @escaping @Sendable (@escaping @Sendable () -> Void) async -> Void) async { + await throwableUntil(timeout: timeout, sourceLocation: SourceLocation(file: file, line: line)) { done in await action(done) } } @@ -100,8 +100,8 @@ public func waitUntil(timeout: NimbleTimeInterval = PollingDefaults.timeout, fil /// /// @warning /// Unlike the synchronous version of this call, this does not support catching Objective-C exceptions. -public func waitUntil(timeout: NimbleTimeInterval = PollingDefaults.timeout, file: FileString = #file, line: UInt = #line, action: sending @escaping (@escaping @Sendable () -> Void) -> Void) async { - await throwableUntil(timeout: timeout, file: file, line: line) { done in +public func waitUntil(timeout: NimbleTimeInterval = PollingDefaults.timeout, file: FileString = #file, line: UInt = #line, action: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void) async { + await throwableUntil(timeout: timeout, sourceLocation: SourceLocation(file: file, line: line)) { done in action(done) } } @@ -113,14 +113,13 @@ private enum ErrorResult { private func throwableUntil( timeout: NimbleTimeInterval, - file: FileString = #file, - line: UInt = #line, - action: sending @escaping (@escaping @Sendable () -> Void) async throws -> Void) async { + sourceLocation: SourceLocation, + action: @escaping @Sendable (@escaping @Sendable () -> Void) async throws -> Void) async { let leeway = timeout.divided let result = await performBlock( timeoutInterval: timeout, leeway: leeway, - file: file, line: line) { @MainActor (done: @escaping @Sendable (ErrorResult) -> Void) async throws -> Void in + sourceLocation: sourceLocation) { @MainActor (done: @escaping @Sendable (ErrorResult) -> Void) async throws -> Void in do { try await action { done(.none) @@ -134,9 +133,9 @@ private func throwableUntil( case .incomplete: internalError("Reached .incomplete state for waitUntil(...).") case .blockedRunLoop: fail(blockedRunLoopErrorMessageFor("-waitUntil()", leeway: leeway), - file: file, line: line) + file: sourceLocation.file, line: sourceLocation.line) case .timedOut: - fail("Waited more than \(timeout.description)", file: file, line: line) + fail("Waited more than \(timeout.description)", file: sourceLocation.file, line: sourceLocation.line) case let .errorThrown(error): fail("Unexpected error thrown: \(error)") case .completed(.error(let error)): diff --git a/Sources/Nimble/DSL+Require.swift b/Sources/Nimble/DSL+Require.swift index e9493716..441f5ea9 100644 --- a/Sources/Nimble/DSL+Require.swift +++ b/Sources/Nimble/DSL+Require.swift @@ -7,7 +7,7 @@ public func require( file: FileString = #file, line: UInt = #line, customError: Error? = nil, - _ expression: @autoclosure @escaping () throws -> sending T? + _ expression: @autoclosure @escaping @Sendable () throws -> sending T? ) -> SyncRequirement { return SyncRequirement( expression: Expression( @@ -26,7 +26,7 @@ public func require( file: FileString = #file, line: UInt = #line, customError: Error? = nil, - _ expression: @autoclosure () -> sending (() throws -> sending T) + _ expression: @autoclosure () -> (@Sendable () throws -> sending T) ) -> SyncRequirement { return SyncRequirement( expression: Expression( @@ -45,7 +45,7 @@ public func require( file: FileString = #file, line: UInt = #line, customError: Error? = nil, - _ expression: @autoclosure () -> sending (() throws -> sending T?) + _ expression: @autoclosure () -> (@Sendable () throws -> sending T?) ) -> SyncRequirement { return SyncRequirement( expression: Expression( @@ -64,7 +64,7 @@ public func require( file: FileString = #file, line: UInt = #line, customError: Error? = nil, - _ expression: @autoclosure () -> sending (() throws -> sending Void) + _ expression: @autoclosure () -> (@Sendable () throws -> Void) ) -> SyncRequirement { return SyncRequirement( expression: Expression( @@ -85,7 +85,7 @@ public func requires( file: FileString = #file, line: UInt = #line, customError: Error? = nil, - _ expression: @autoclosure @escaping () throws -> sending T? + _ expression: @autoclosure @escaping @Sendable () throws -> sending T? ) -> SyncRequirement { return SyncRequirement( expression: Expression( @@ -106,7 +106,7 @@ public func requires( file: FileString = #file, line: UInt = #line, customError: Error? = nil, - _ expression: @autoclosure () -> sending (() throws -> sending T) + _ expression: @autoclosure () -> (@Sendable () throws -> sending T) ) -> SyncRequirement { return SyncRequirement( expression: Expression( @@ -127,7 +127,7 @@ public func requires( file: FileString = #file, line: UInt = #line, customError: Error? = nil, - _ expression: @autoclosure () -> sending (() throws -> sending T?) + _ expression: @autoclosure () -> (@Sendable () throws -> sending T?) ) -> SyncRequirement { return SyncRequirement( expression: Expression( @@ -148,7 +148,7 @@ public func requires( file: FileString = #file, line: UInt = #line, customError: Error? = nil, - _ expression: @autoclosure () -> sending (() throws -> sending Void) + _ expression: @autoclosure () -> (@Sendable () throws -> sending Void) ) -> SyncRequirement { return SyncRequirement( expression: Expression( @@ -260,7 +260,7 @@ public func unwrap( file: FileString = #file, line: UInt = #line, customError: Error? = nil, - _ expression: @autoclosure @escaping () throws -> sending T? + _ expression: @autoclosure @escaping @Sendable () throws -> sending T? ) throws -> T { try requires(file: file, line: line, customError: customError, expression()).toNot(beNil()) } @@ -275,7 +275,7 @@ public func unwrap( file: FileString = #file, line: UInt = #line, customError: Error? = nil, - _ expression: @autoclosure () -> sending (() throws -> sending T?) + _ expression: @autoclosure () -> (@Sendable () throws -> sending T?) ) throws -> T { try requires(file: file, line: line, customError: customError, expression()).toNot(beNil()) } @@ -290,7 +290,7 @@ public func unwraps( file: FileString = #file, line: UInt = #line, customError: Error? = nil, - _ expression: @autoclosure @escaping () throws -> sending T? + _ expression: @autoclosure @escaping @Sendable () throws -> sending T? ) throws -> T { try requires(file: file, line: line, customError: customError, expression()).toNot(beNil()) } @@ -305,7 +305,7 @@ public func unwraps( file: FileString = #file, line: UInt = #line, customError: Error? = nil, - _ expression: @autoclosure () -> sending (() throws -> sending T?) + _ expression: @autoclosure () -> (@Sendable () throws -> sending T?) ) throws -> T { try requires(file: file, line: line, customError: customError, expression()).toNot(beNil()) } diff --git a/Sources/Nimble/DSL+Wait.swift b/Sources/Nimble/DSL+Wait.swift index 72344ec0..3c4ece7d 100644 --- a/Sources/Nimble/DSL+Wait.swift +++ b/Sources/Nimble/DSL+Wait.swift @@ -21,7 +21,7 @@ public class NMBWait: NSObject { timeout: TimeInterval, file: FileString = #file, line: UInt = #line, - action: sending @escaping (@escaping @Sendable () -> Void) -> Void) { + action: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void) { // Convert TimeInterval to NimbleTimeInterval until(timeout: timeout.nimbleInterval, file: file, line: line, action: action) } @@ -31,7 +31,7 @@ public class NMBWait: NSObject { timeout: NimbleTimeInterval, file: FileString = #file, line: UInt = #line, - action: sending @escaping (@escaping @Sendable () -> Void) -> Void) { + action: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void) { return throwableUntil(timeout: timeout, file: file, line: line) { done in action(done) } @@ -42,9 +42,10 @@ public class NMBWait: NSObject { timeout: NimbleTimeInterval, file: FileString = #file, line: UInt = #line, - action: sending @escaping (@escaping @Sendable () -> Void) throws -> Void) { + action: @escaping @Sendable (@escaping @Sendable () -> Void) throws -> Void) { let awaiter = NimbleEnvironment.activeInstance.awaiter let leeway = timeout.divided + let location = SourceLocation(file: file, line: line) let result = awaiter.performBlock(file: file, line: line) { (done: @escaping @Sendable (ErrorResult) -> Void) throws -> Void in DispatchQueue.main.async { let capture = NMBExceptionCapture( @@ -63,7 +64,9 @@ public class NMBWait: NSObject { } } } - }.timeout(timeout, forcefullyAbortTimeout: leeway).wait("waitUntil(...)", file: file, line: line) + } + .timeout(timeout, forcefullyAbortTimeout: leeway) + .wait("waitUntil(...)", sourceLocation: location) switch result { case .incomplete: internalError("Reached .incomplete state for waitUntil(...).") @@ -90,7 +93,7 @@ public class NMBWait: NSObject { public class func until( _ file: FileString = #file, line: UInt = #line, - action: sending @escaping (@escaping @Sendable () -> Void) -> Void) { + action: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void) { until(timeout: .seconds(1), file: file, line: line, action: action) } #else @@ -116,7 +119,12 @@ internal func blockedRunLoopErrorMessageFor(_ fnName: String, leeway: NimbleTime /// This function manages the main run loop (`NSRunLoop.mainRunLoop()`) while this function /// is executing. Any attempts to touch the run loop may cause non-deterministic behavior. @available(*, noasync, message: "the sync variant of `waitUntil` does not work in async contexts. Use the async variant as a drop-in replacement") -public func waitUntil(timeout: NimbleTimeInterval = PollingDefaults.timeout, file: FileString = #file, line: UInt = #line, action: sending @escaping (@escaping @Sendable () -> Void) -> Void) { +public func waitUntil( + timeout: NimbleTimeInterval = PollingDefaults.timeout, + file: FileString = #file, + line: UInt = #line, + action: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void +) { NMBWait.until(timeout: timeout, file: file, line: line, action: action) } diff --git a/Sources/Nimble/DSL.swift b/Sources/Nimble/DSL.swift index f664a02c..f97bef84 100644 --- a/Sources/Nimble/DSL.swift +++ b/Sources/Nimble/DSL.swift @@ -2,7 +2,7 @@ public func expect( file: FileString = #file, line: UInt = #line, - _ expression: @autoclosure @escaping () throws -> sending T? + _ expression: @autoclosure @escaping @Sendable () throws -> sending T? ) -> SyncExpectation { return SyncExpectation( expression: Expression( @@ -15,7 +15,7 @@ public func expect( public func expect( file: FileString = #file, line: UInt = #line, - _ expression: @autoclosure () -> sending (() throws -> sending T) + _ expression: @autoclosure () -> (@Sendable () throws -> sending T) ) -> SyncExpectation { return SyncExpectation( expression: Expression( @@ -28,7 +28,7 @@ public func expect( public func expect( file: FileString = #file, line: UInt = #line, - _ expression: @autoclosure () -> sending (() throws -> sending T?) + _ expression: @autoclosure () -> (@Sendable () throws -> sending T?) ) -> SyncExpectation { return SyncExpectation( expression: Expression( @@ -41,7 +41,7 @@ public func expect( public func expect( file: FileString = #file, line: UInt = #line, - _ expression: @autoclosure () -> sending (() throws -> sending Void) + _ expression: @autoclosure () -> (@Sendable () throws -> Void) ) -> SyncExpectation { return SyncExpectation( expression: Expression( @@ -55,7 +55,7 @@ public func expect( public func expects( file: FileString = #file, line: UInt = #line, - _ expression: @autoclosure @escaping () throws -> sending T? + _ expression: @autoclosure @escaping @Sendable () throws -> sending T? ) -> SyncExpectation { return SyncExpectation( expression: Expression( @@ -69,7 +69,7 @@ public func expects( public func expects( file: FileString = #file, line: UInt = #line, - _ expression: @autoclosure () -> sending (() throws -> sending T) + _ expression: @autoclosure () -> (@Sendable () throws -> sending T) ) -> SyncExpectation { return SyncExpectation( expression: Expression( @@ -83,7 +83,7 @@ public func expects( public func expects( file: FileString = #file, line: UInt = #line, - _ expression: @autoclosure () -> sending (() throws -> sending T?) + _ expression: @autoclosure () -> (@Sendable () throws -> sending T?) ) -> SyncExpectation { return SyncExpectation( expression: Expression( @@ -97,7 +97,7 @@ public func expects( public func expects( file: FileString = #file, line: UInt = #line, - _ expression: @autoclosure () -> sending (() throws -> sending Void) + _ expression: @autoclosure () -> (@Sendable () throws -> Void) ) -> SyncExpectation { return SyncExpectation( expression: Expression( diff --git a/Sources/Nimble/Expectation.swift b/Sources/Nimble/Expectation.swift index 7363483b..e001a2a2 100644 --- a/Sources/Nimble/Expectation.swift +++ b/Sources/Nimble/Expectation.swift @@ -150,7 +150,7 @@ extension Expectation { } } -public struct SyncExpectation: Expectation { +public struct SyncExpectation: Expectation, Sendable { public let expression: Expression /// The status of the test after matchers have been evaluated. diff --git a/Sources/Nimble/Expression.swift b/Sources/Nimble/Expression.swift index be6d8d01..c41c7b5e 100644 --- a/Sources/Nimble/Expression.swift +++ b/Sources/Nimble/Expression.swift @@ -1,15 +1,30 @@ -// Memoizes the given closure, only calling the passed -// closure once; even if repeat calls to the returned closure -private func memoizedClosure(_ closure: @escaping () throws -> T) -> (Bool) throws -> T { - var cache: T? - return { withoutCaching in - if withoutCaching || cache == nil { - cache = try closure() +import Foundation + +private final class MemoizedValue: Sendable { + private let lock = NSRecursiveLock() + nonisolated(unsafe) private var cache: T? = nil + private let closure: @Sendable () throws -> sending T + + init(_ closure: @escaping @Sendable () throws -> sending T) { + self.closure = closure + } + + @Sendable func evaluate(withoutCaching: Bool) throws -> sending T { + try lock.withLock { + if withoutCaching || cache == nil { + cache = try closure() + } + return cache! } - return cache! } } +// Memoizes the given closure, only calling the passed +// closure once; even if repeat calls to the returned closure +private func memoizedClosure(_ closure: @escaping @Sendable () throws -> sending T) -> @Sendable (Bool) throws -> sending T { + MemoizedValue(closure).evaluate(withoutCaching:) +} + /// Expression represents the closure of the value inside expect(...). /// Expressions are memoized by default. This makes them safe to call /// evaluate() multiple times without causing a re-evaluation of the underlying @@ -21,8 +36,8 @@ private func memoizedClosure(_ closure: @escaping () throws -> T) -> (Bool) t /// /// This provides a common consumable API for matchers to utilize to allow /// Nimble to change internals to how the captured closure is managed. -public struct Expression { - internal let _expression: (Bool) throws -> sending Value? +public struct Expression: Sendable { + internal let _expression: @Sendable (Bool) throws -> sending Value? internal let _withoutCaching: Bool public let location: SourceLocation public let isClosure: Bool @@ -38,7 +53,7 @@ public struct Expression { /// requires an explicit closure. This gives Nimble /// flexibility if @autoclosure behavior changes between /// Swift versions. Nimble internals always sets this true. - public init(expression: @escaping () throws -> sending Value?, location: SourceLocation, isClosure: Bool = true) { + public init(expression: @escaping @Sendable () throws -> sending Value?, location: SourceLocation, isClosure: Bool = true) { self._expression = memoizedClosure(expression) self.location = location self._withoutCaching = false @@ -59,7 +74,7 @@ public struct Expression { /// requires an explicit closure. This gives Nimble /// flexibility if @autoclosure behavior changes between /// Swift versions. Nimble internals always sets this true. - public init(memoizedExpression: @escaping (Bool) throws -> sending Value?, location: SourceLocation, withoutCaching: Bool, isClosure: Bool = true) { + public init(memoizedExpression: @escaping @Sendable (Bool) throws -> sending Value?, location: SourceLocation, withoutCaching: Bool, isClosure: Bool = true) { self._expression = memoizedExpression self.location = location self._withoutCaching = withoutCaching @@ -74,7 +89,7 @@ public struct Expression { /// /// - Parameter block: The block that can cast the current Expression value to a /// new type. - public func cast(_ block: @escaping (Value?) throws -> sending U?) -> Expression { + public func cast(_ block: @escaping @Sendable (Value?) throws -> sending U?) -> Expression { Expression( expression: ({ try block(self.evaluate()) }), location: self.location, @@ -103,7 +118,9 @@ public struct Expression { isClosure: isClosure ) } +} +extension Expression where Value: Sendable { public func toAsyncExpression() -> AsyncExpression { AsyncExpression( memoizedExpression: { @MainActor memoize in try _expression(memoize) }, diff --git a/Sources/Nimble/Matchers/AllPass.swift b/Sources/Nimble/Matchers/AllPass.swift index 133c21ec..f1f6e2b5 100644 --- a/Sources/Nimble/Matchers/AllPass.swift +++ b/Sources/Nimble/Matchers/AllPass.swift @@ -1,5 +1,5 @@ public func allPass( - _ passFunc: @escaping (S.Element) throws -> Bool + _ passFunc: @escaping @Sendable (S.Element) throws -> Bool ) -> Matcher { let matcher = Matcher.define("pass a condition") { actualExpression, message in guard let actual = try actualExpression.evaluate() else { @@ -12,7 +12,7 @@ public func allPass( public func allPass( _ passName: String, - _ passFunc: @escaping (S.Element) throws -> Bool + _ passFunc: @escaping @Sendable (S.Element) throws -> Bool ) -> Matcher { let matcher = Matcher.define(passName) { actualExpression, message in guard let actual = try actualExpression.evaluate() else { diff --git a/Sources/Nimble/Matchers/AsyncAllPass.swift b/Sources/Nimble/Matchers/AsyncAllPass.swift index ec04f9eb..d12c38b2 100644 --- a/Sources/Nimble/Matchers/AsyncAllPass.swift +++ b/Sources/Nimble/Matchers/AsyncAllPass.swift @@ -1,6 +1,6 @@ public func allPass( - _ passFunc: @escaping (S.Element) async throws -> Bool -) -> AsyncMatcher { + _ passFunc: @escaping @Sendable (S.Element) async throws -> Bool +) -> AsyncMatcher where S.Element: Sendable { let matcher = AsyncMatcher.define("pass a condition") { actualExpression, message in guard let actual = try await actualExpression.evaluate() else { return MatcherResult(status: .fail, message: message) @@ -12,8 +12,8 @@ public func allPass( public func allPass( _ passName: String, - _ passFunc: @escaping (S.Element) async throws -> Bool -) -> AsyncMatcher { + _ passFunc: @escaping @Sendable (S.Element) async throws -> Bool +) -> AsyncMatcher where S.Element: Sendable { let matcher = AsyncMatcher.define(passName) { actualExpression, message in guard let actual = try await actualExpression.evaluate() else { return MatcherResult(status: .fail, message: message) diff --git a/Sources/Nimble/Matchers/BeEmpty.swift b/Sources/Nimble/Matchers/BeEmpty.swift index 571797ca..43e1ca79 100644 --- a/Sources/Nimble/Matchers/BeEmpty.swift +++ b/Sources/Nimble/Matchers/BeEmpty.swift @@ -85,10 +85,10 @@ extension NMBMatcher { let actualValue = try actualExpression.evaluate() if let value = actualValue as? NMBCollection { - let expr = Expression(expression: ({ value }), location: location) + let expr = Expression(expression: { value }, location: location) return try beEmpty().satisfies(expr).toObjectiveC() } else if let value = actualValue as? NSString { - let expr = Expression(expression: ({ value }), location: location) + let expr = Expression(expression: { value }, location: location) return try beEmpty().satisfies(expr).toObjectiveC() } else if let actualValue = actualValue { let badTypeErrorMsg = "be empty (only works for NSArrays, NSSets, NSIndexSets, NSDictionaries, NSHashTables, and NSStrings)" diff --git a/Sources/Nimble/Matchers/Equal+Tuple.swift b/Sources/Nimble/Matchers/Equal+Tuple.swift index 17b2f2e5..9abe3411 100644 --- a/Sources/Nimble/Matchers/Equal+Tuple.swift +++ b/Sources/Nimble/Matchers/Equal+Tuple.swift @@ -7,7 +7,7 @@ public func equal( _ expectedValue: (T1, T2)? ) -> Matcher<(T1, T2)> { - equal(expectedValue, by: ==) + equal(expectedValue, by: { $0 == $1 }) } public func == ( @@ -45,7 +45,7 @@ public func != ( public func equal( _ expectedValue: (T1, T2, T3)? ) -> Matcher<(T1, T2, T3)> { - equal(expectedValue, by: ==) + equal(expectedValue, by: { $0 == $1 }) } public func == ( @@ -85,7 +85,7 @@ public func != ( public func equal( _ expectedValue: (T1, T2, T3, T4)? ) -> Matcher<(T1, T2, T3, T4)> { - equal(expectedValue, by: ==) + equal(expectedValue, by: { $0 == $1 }) } public func == ( @@ -124,7 +124,7 @@ public func != ( public func equal( _ expectedValue: (T1, T2, T3, T4, T5)? ) -> Matcher<(T1, T2, T3, T4, T5)> { - equal(expectedValue, by: ==) + equal(expectedValue, by: { $0 == $1 }) } public func == ( @@ -164,7 +164,7 @@ public func != ( _ expectedValue: (T1, T2, T3, T4, T5, T6)? ) -> Matcher<(T1, T2, T3, T4, T5, T6)> { - equal(expectedValue, by: ==) + equal(expectedValue, by: { $0 == $1 }) } public func == ( diff --git a/Sources/Nimble/Matchers/Equal+TupleArray.swift b/Sources/Nimble/Matchers/Equal+TupleArray.swift index eff6168d..3139c112 100644 --- a/Sources/Nimble/Matchers/Equal+TupleArray.swift +++ b/Sources/Nimble/Matchers/Equal+TupleArray.swift @@ -7,7 +7,7 @@ public func equal( _ expectedValue: [(T1, T2)]? ) -> Matcher<[(T1, T2)]> { - equalTupleArray(expectedValue, by: ==) + equalTupleArray(expectedValue, by: { $0 == $1 }) } public func == ( @@ -45,7 +45,7 @@ public func != ( public func equal( _ expectedValue: [(T1, T2, T3)]? ) -> Matcher<[(T1, T2, T3)]> { - equalTupleArray(expectedValue, by: ==) + equalTupleArray(expectedValue, by: { $0 == $1 }) } public func == ( @@ -83,7 +83,7 @@ public func != ( public func equal( _ expectedValue: [(T1, T2, T3, T4)]? ) -> Matcher<[(T1, T2, T3, T4)]> { - equalTupleArray(expectedValue, by: ==) + equalTupleArray(expectedValue, by: { $0 == $1 }) } public func == ( @@ -121,7 +121,7 @@ public func != ( public func equal( _ expectedValue: [(T1, T2, T3, T4, T5)]? ) -> Matcher<[(T1, T2, T3, T4, T5)]> { - equalTupleArray(expectedValue, by: ==) + equalTupleArray(expectedValue, by: { $0 == $1 }) } public func == ( @@ -159,7 +159,7 @@ public func != ( _ expectedValue: [(T1, T2, T3, T4, T5, T6)]? ) -> Matcher<[(T1, T2, T3, T4, T5, T6)]> { - equalTupleArray(expectedValue, by: ==) + equalTupleArray(expectedValue, by: { $0 == $1 }) } public func == ( @@ -196,7 +196,7 @@ public func != ( _ expectedValue: [(Tuple)]?, - by areTuplesEquivalent: @escaping (Tuple, Tuple) -> Bool + by areTuplesEquivalent: @escaping @Sendable (Tuple, Tuple) -> Bool ) -> Matcher<[Tuple]> { equal(expectedValue) { $0.elementsEqual($1, by: areTuplesEquivalent) diff --git a/Sources/Nimble/Matchers/Equal.swift b/Sources/Nimble/Matchers/Equal.swift index 4ec21e37..1a5ce017 100644 --- a/Sources/Nimble/Matchers/Equal.swift +++ b/Sources/Nimble/Matchers/Equal.swift @@ -1,6 +1,6 @@ internal func equal( _ expectedValue: T?, - by areEquivalent: @escaping (T, T) -> Bool + by areEquivalent: @escaping @Sendable (T, T) -> Bool ) -> Matcher { Matcher.define("equal <\(stringify(expectedValue))>") { actualExpression, msg in let actualValue = try actualExpression.evaluate() diff --git a/Sources/Nimble/Matchers/Match.swift b/Sources/Nimble/Matchers/Match.swift index b634ad31..1ed3f3c9 100644 --- a/Sources/Nimble/Matchers/Match.swift +++ b/Sources/Nimble/Matchers/Match.swift @@ -14,9 +14,10 @@ import class Foundation.NSString extension NMBMatcher { @objc public class func matchMatcher(_ expected: NSString) -> NMBMatcher { + let expected = String(expected) return NMBMatcher { actualExpression in let actual = actualExpression.cast { $0 as? String } - return try match(expected.description).satisfies(actual).toObjectiveC() + return try match(expected).satisfies(actual).toObjectiveC() } } } diff --git a/Sources/Nimble/Matchers/Matcher.swift b/Sources/Nimble/Matchers/Matcher.swift index cb994d56..ffb5798b 100644 --- a/Sources/Nimble/Matchers/Matcher.swift +++ b/Sources/Nimble/Matchers/Matcher.swift @@ -18,7 +18,7 @@ /// In the 2023 Apple Platform releases (macOS 14, iOS 17, watchOS 10, tvOS 17, visionOS 1), Apple /// renamed `NSMatcher` to `Matcher`. In response, we decided to rename `Matcher` to /// `Matcher`. -public struct Matcher { +public struct Matcher: Sendable { fileprivate let matcher: @Sendable (Expression) throws -> MatcherResult /// Constructs a matcher that knows how take a given value @@ -39,8 +39,6 @@ public struct Matcher { @available(*, deprecated, renamed: "Matcher") public typealias Predicate = Matcher -extension Matcher: Sendable where T: Sendable {} - /// Provides convenience helpers to defining matchers extension Matcher { /// Like Matcher() constructor, but automatically guard against nil (actual) values diff --git a/Sources/Nimble/Matchers/RaisesException.swift b/Sources/Nimble/Matchers/RaisesException.swift index 2bb94094..d1d9b464 100644 --- a/Sources/Nimble/Matchers/RaisesException.swift +++ b/Sources/Nimble/Matchers/RaisesException.swift @@ -141,7 +141,7 @@ internal func exceptionMatchesNonNilFieldsOrClosure( return matches } -public class NMBObjCRaiseExceptionMatcher: NMBMatcher { +public class NMBObjCRaiseExceptionMatcher: NMBMatcher, @unchecked Sendable { private let _name: String? private let _reason: String? private let _userInfo: NSDictionary? diff --git a/Sources/Nimble/Matchers/SatisfyAllOf.swift b/Sources/Nimble/Matchers/SatisfyAllOf.swift index 30f9045a..a9d55e35 100644 --- a/Sources/Nimble/Matchers/SatisfyAllOf.swift +++ b/Sources/Nimble/Matchers/SatisfyAllOf.swift @@ -57,7 +57,7 @@ public func satisfyAllOf(_ matchers: any AsyncableMatcher...) -> AsyncMatc @available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) public func satisfyAllOf(_ matchers: [any AsyncableMatcher]) -> AsyncMatcher { return AsyncMatcher.define { actualExpression in - let cachedExpression = actualExpression.withCaching() + let cachedExpression = await actualExpression.withCaching() var postfixMessages = [String]() var status: MatcherStatus = .matches for matcher in matchers { diff --git a/Sources/Nimble/Matchers/SatisfyAnyOf.swift b/Sources/Nimble/Matchers/SatisfyAnyOf.swift index 56ffdd10..adb3ebbc 100644 --- a/Sources/Nimble/Matchers/SatisfyAnyOf.swift +++ b/Sources/Nimble/Matchers/SatisfyAnyOf.swift @@ -57,7 +57,7 @@ public func satisfyAnyOf(_ matchers: any AsyncableMatcher...) -> AsyncMatc @available(macOS 13.0.0, iOS 16.0.0, tvOS 16.0.0, watchOS 9.0.0, *) public func satisfyAnyOf(_ matchers: [any AsyncableMatcher]) -> AsyncMatcher { return AsyncMatcher.define { actualExpression in - let cachedExpression = actualExpression.withCaching() + let cachedExpression = await actualExpression.withCaching() var postfixMessages = [String]() var status: MatcherStatus = .doesNotMatch for matcher in matchers { diff --git a/Sources/Nimble/Matchers/ThrowError.swift b/Sources/Nimble/Matchers/ThrowError.swift index 32c2f6c1..818ea4eb 100644 --- a/Sources/Nimble/Matchers/ThrowError.swift +++ b/Sources/Nimble/Matchers/ThrowError.swift @@ -43,7 +43,7 @@ public func throwError() -> Matcher { /// /// nil arguments indicates that the matcher should not attempt to match against /// that parameter. -public func throwError(_ error: T, closure: ((Error) -> Void)? = nil) -> Matcher { +public func throwError(_ error: T, closure: (@Sendable (Error) -> Void)? = nil) -> Matcher { return Matcher { actualExpression in var actualError: Error? do { @@ -89,7 +89,7 @@ public func throwError(_ error: T, closure: ((Error) -> Void)? = /// /// nil arguments indicates that the matcher should not attempt to match against /// that parameter. -public func throwError(_ error: T, closure: ((T) -> Void)? = nil) -> Matcher { +public func throwError(_ error: T, closure: (@Sendable (T) -> Void)? = nil) -> Matcher { return Matcher { actualExpression in var actualError: Error? do { @@ -137,7 +137,7 @@ public func throwError(_ error: T, closure: ((T) -> V /// that parameter. public func throwError( errorType: T.Type, - closure: ((T) -> Void)? = nil + closure: (@Sendable (T) -> Void)? = nil ) -> Matcher { return Matcher { actualExpression in var actualError: Error? @@ -197,7 +197,7 @@ public func throwError( /// values of the existential type `Error` in the closure. /// /// The closure only gets called when an error was thrown. -public func throwError(closure: @escaping ((Error) -> Void)) -> Matcher { +public func throwError(closure: @escaping (@Sendable (Error) -> Void)) -> Matcher { return Matcher { actualExpression in var actualError: Error? do { @@ -232,7 +232,7 @@ public func throwError(closure: @escaping ((Error) -> Void)) -> Matcher(closure: @escaping ((T) -> Void)) -> Matcher { +public func throwError(closure: @escaping (@Sendable (T) -> Void)) -> Matcher { return Matcher { actualExpression in var actualError: Error? do { diff --git a/Sources/Nimble/Polling+AsyncAwait.swift b/Sources/Nimble/Polling+AsyncAwait.swift index 8627e3e3..030b7a11 100644 --- a/Sources/Nimble/Polling+AsyncAwait.swift +++ b/Sources/Nimble/Polling+AsyncAwait.swift @@ -27,7 +27,7 @@ internal func execute( } } -internal actor Poller { +internal actor Poller { private var lastMatcherResult: MatcherResult? init() {} @@ -41,11 +41,10 @@ internal actor Poller { fnName: String, matcherRunner: @escaping @Sendable () async throws -> MatcherResult) async -> MatcherResult { let fnName = "expect(...).\(fnName)(...)" - let result = await pollBlock( + let result = await asyncPollBlock( pollInterval: poll, timeoutInterval: timeout, - file: expression.location.file, - line: expression.location.line, + sourceLocation: expression.location, fnName: fnName) { if await self.updateMatcherResult(result: try await matcherRunner()) .toBoolean(expectation: style) { @@ -92,7 +91,7 @@ internal func poll( ) } -extension SyncExpectation { +extension SyncExpectation where Value: Sendable { // MARK: - With Synchronous Matchers /// Tests the actual value using a matcher to match by checking continuously /// at each pollInterval until the timeout is reached. @@ -257,7 +256,9 @@ extension SyncExpectation { ) async -> Self { return await toAlways(matcher, until: until, pollInterval: pollInterval, description: description) } +} +extension SyncExpectation where Value: Sendable { // MARK: - With AsyncMatchers /// Tests the actual value using a matcher to match by checking continuously /// at each pollInterval until the timeout is reached. diff --git a/Sources/Nimble/Polling+Require.swift b/Sources/Nimble/Polling+Require.swift index 09ee815d..765ce3be 100644 --- a/Sources/Nimble/Polling+Require.swift +++ b/Sources/Nimble/Polling+Require.swift @@ -708,28 +708,28 @@ extension AsyncRequirement { /// Makes sure that the expression evaluates to a non-nil value, otherwise throw an error. /// As you can tell, this is a much less verbose equivalent to `require(expression).toEventuallyNot(beNil())` @discardableResult -public func pollUnwrap(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure @escaping () throws -> T?) throws -> T { +public func pollUnwrap(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure @escaping @Sendable () throws -> T?) throws -> T { try require(file: file, line: line, expression()).toEventuallyNot(beNil()) } /// Makes sure that the expression evaluates to a non-nil value, otherwise throw an error. /// As you can tell, this is a much less verbose equivalent to `require(expression).toEventuallyNot(beNil())` @discardableResult -public func pollUnwrap(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure () -> (() throws -> T?)) throws -> T { +public func pollUnwrap(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure () -> (@Sendable () throws -> T?)) throws -> T { try require(file: file, line: line, expression()).toEventuallyNot(beNil()) } /// Makes sure that the expression evaluates to a non-nil value, otherwise throw an error. /// As you can tell, this is a much less verbose equivalent to `require(expression).toEventuallyNot(beNil())` @discardableResult -public func pollUnwraps(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure @escaping () throws -> T?) throws -> T { +public func pollUnwraps(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure @escaping @Sendable () throws -> T?) throws -> T { try require(file: file, line: line, expression()).toEventuallyNot(beNil()) } /// Makes sure that the expression evaluates to a non-nil value, otherwise throw an error. /// As you can tell, this is a much less verbose equivalent to `require(expression).toEventuallyNot(beNil())` @discardableResult -public func pollUnwraps(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure () -> (() throws -> T?)) throws -> T { +public func pollUnwraps(file: FileString = #file, line: UInt = #line, _ expression: @autoclosure () -> (@Sendable () throws -> T?)) throws -> T { try require(file: file, line: line, expression()).toEventuallyNot(beNil()) } diff --git a/Sources/Nimble/Polling.swift b/Sources/Nimble/Polling.swift index abb64e94..f54b4caf 100644 --- a/Sources/Nimble/Polling.swift +++ b/Sources/Nimble/Polling.swift @@ -37,8 +37,8 @@ public struct AsyncDefaults { public struct PollingDefaults: @unchecked Sendable { private static let lock = NSRecursiveLock() - private static var _timeout: NimbleTimeInterval = .seconds(1) - private static var _pollInterval: NimbleTimeInterval = .milliseconds(10) + nonisolated(unsafe) private static var _timeout: NimbleTimeInterval = .seconds(1) + nonisolated(unsafe) private static var _pollInterval: NimbleTimeInterval = .milliseconds(10) public static var timeout: NimbleTimeInterval { get { @@ -95,8 +95,7 @@ internal func poll( let result = pollBlock( pollInterval: poll, timeoutInterval: timeout, - file: actualExpression.location.file, - line: actualExpression.location.line, + sourceLocation: actualExpression.location, fnName: fnName) { lastMatcherResult = try matcher.satisfies(uncachedExpression) if lastMatcherResult!.toBoolean(expectation: style) { diff --git a/Sources/Nimble/Requirement.swift b/Sources/Nimble/Requirement.swift index 6f70d224..9e573750 100644 --- a/Sources/Nimble/Requirement.swift +++ b/Sources/Nimble/Requirement.swift @@ -71,7 +71,7 @@ internal func executeRequire(_ expression: AsyncExpression, _ style: Expec } } -public struct SyncRequirement { +public struct SyncRequirement: Sendable { public let expression: Expression /// A custom error to throw. diff --git a/Sources/Nimble/Utils/AsyncAwait.swift b/Sources/Nimble/Utils/AsyncAwait.swift index 11a5187e..8626d1cd 100644 --- a/Sources/Nimble/Utils/AsyncAwait.swift +++ b/Sources/Nimble/Utils/AsyncAwait.swift @@ -198,13 +198,13 @@ private func runPoller( timeoutInterval: NimbleTimeInterval, pollInterval: NimbleTimeInterval, awaiter: Awaiter, - fnName: String = #function, file: FileString = #file, line: UInt = #line, + fnName: String, + sourceLocation: SourceLocation, expression: @escaping @Sendable () async throws -> PollStatus ) async -> AsyncPollResult { awaiter.waitLock.acquireWaitingLock( fnName, - file: file, - line: line) + sourceLocation: sourceLocation) defer { awaiter.waitLock.releaseWaitingLock() @@ -257,8 +257,8 @@ private func runAwaitTrigger( awaiter: Awaiter, timeoutInterval: NimbleTimeInterval, leeway: NimbleTimeInterval, - file: FileString, line: UInt, - _ closure: @escaping (@escaping @Sendable (T) -> Void) async throws -> Void + sourceLocation: SourceLocation, + _ closure: @escaping @Sendable (@escaping @Sendable (T) -> Void) async throws -> Void ) async -> AsyncPollResult { let timeoutQueue = awaiter.timeoutQueue let completionCount = Box(value: 0) @@ -284,7 +284,7 @@ private func runAwaitTrigger( promise.send(result) } else { fail("waitUntil(..) expects its completion closure to be only called once", - file: file, line: line) + file: sourceLocation.file, line: sourceLocation.line) } } if let value = await promise.value { @@ -308,27 +308,28 @@ private func runAwaitTrigger( internal func performBlock( timeoutInterval: NimbleTimeInterval, leeway: NimbleTimeInterval, - file: FileString, line: UInt, - _ closure: @escaping (@escaping @Sendable (T) -> Void) async throws -> Void + sourceLocation: SourceLocation, + _ closure: @escaping @Sendable (@escaping @Sendable (T) -> Void) async throws -> Void ) async -> AsyncPollResult { await runAwaitTrigger( awaiter: NimbleEnvironment.activeInstance.awaiter, timeoutInterval: timeoutInterval, leeway: leeway, - file: file, line: line, closure) + sourceLocation: sourceLocation, closure) } -internal func pollBlock( +internal func asyncPollBlock( pollInterval: NimbleTimeInterval, timeoutInterval: NimbleTimeInterval, - file: FileString, - line: UInt, - fnName: String = #function, + sourceLocation: SourceLocation, + fnName: String, expression: @escaping @Sendable () async throws -> PollStatus) async -> AsyncPollResult { await runPoller( timeoutInterval: timeoutInterval, pollInterval: pollInterval, awaiter: NimbleEnvironment.activeInstance.awaiter, + fnName: fnName, + sourceLocation: sourceLocation, expression: expression ) } diff --git a/Sources/Nimble/Utils/PollAwait.swift b/Sources/Nimble/Utils/PollAwait.swift index 61714bb1..bc68f58f 100644 --- a/Sources/Nimble/Utils/PollAwait.swift +++ b/Sources/Nimble/Utils/PollAwait.swift @@ -12,16 +12,15 @@ private let pollLeeway = NimbleTimeInterval.milliseconds(1) /// Stores debugging information about callers internal struct WaitingInfo: CustomStringConvertible, Sendable { let name: String - let file: FileString - let lineNumber: UInt + let sourceLocation: SourceLocation var description: String { - return "\(name) at \(file):\(lineNumber)" + return "\(name) at \(sourceLocation)" } } internal protocol WaitLock { - func acquireWaitingLock(_ fnName: String, file: FileString, line: UInt) + func acquireWaitingLock(_ fnName: String, sourceLocation: SourceLocation) func releaseWaitingLock() func isWaitingLocked() -> Bool } @@ -32,10 +31,10 @@ internal final class AssertionWaitLock: WaitLock, @unchecked Sendable { init() { } - func acquireWaitingLock(_ fnName: String, file: FileString, line: UInt) { + func acquireWaitingLock(_ fnName: String, sourceLocation: SourceLocation) { lock.lock() defer { lock.unlock() } - let info = WaitingInfo(name: fnName, file: file, lineNumber: line) + let info = WaitingInfo(name: fnName, sourceLocation: sourceLocation) nimblePrecondition( currentWaiter == nil, "InvalidNimbleAPIUsage", @@ -263,11 +262,10 @@ internal class AwaitPromiseBuilder { /// - The async expectation raised an unexpected error (swift) /// /// The returned PollResult will NEVER be .incomplete. - func wait(_ fnName: String = #function, file: FileString = #file, line: UInt = #line) -> PollResult { + func wait(_ fnName: String = #function, sourceLocation: SourceLocation) -> PollResult { waitLock.acquireWaitingLock( fnName, - file: file, - line: line) + sourceLocation: sourceLocation) let capture = NMBExceptionCapture(handler: ({ exception in _ = self.promise.resolveResult(.raisedException(exception)) @@ -404,9 +402,8 @@ internal class Awaiter { internal func pollBlock( pollInterval: NimbleTimeInterval, timeoutInterval: NimbleTimeInterval, - file: FileString, - line: UInt, - fnName: String = #function, + sourceLocation: SourceLocation, + fnName: String, expression: @escaping () throws -> PollStatus) -> PollResult { let awaiter = NimbleEnvironment.activeInstance.awaiter let result = awaiter.poll(pollInterval) { () throws -> Bool? in @@ -416,7 +413,7 @@ internal func pollBlock( return nil } .timeout(timeoutInterval, forcefullyAbortTimeout: timeoutInterval.divided) - .wait(fnName, file: file, line: line) + .wait(fnName, sourceLocation: sourceLocation) return result }