From 1a33d23867ef8e461d8f75ab3433df26cfb07a60 Mon Sep 17 00:00:00 2001 From: Noah Durell Date: Tue, 5 Nov 2024 14:29:40 -0500 Subject: [PATCH] vendorize more things --- .swiftformat | 2 +- .swiftlint.yml | 3 + Package.resolved | 11 +- Package.swift | 29 +- Package@swift-6.0.swift | 28 +- Sources/ComposableArchitecture/Effect.swift | 4 +- .../Effects/Cancellation.swift | 2 +- .../Internal/Create.swift | 2 +- .../Observation/ObservableState.swift | 2 +- .../Observation/Observation+State.swift | 2 +- .../ObservationStateRegistrar.swift | 2 +- .../ComposableArchitecture/RootStore.swift | 4 +- Sources/ComposableArchitecture/Store.swift | 2 +- Sources/ConcurrencyExtras/ActorIsolated.swift | 113 ++++ .../AnyHashableSendable.swift | 42 ++ Sources/ConcurrencyExtras/AsyncStream.swift | 85 +++ .../AsyncThrowingStream.swift | 40 ++ .../ConcurrencyExtras/Internal/Locking.swift | 11 + .../Internal/UncheckedBox.swift | 6 + Sources/ConcurrencyExtras/LockIsolated.swift | 119 +++++ .../MainSerialExecutor.swift | 90 ++++ Sources/ConcurrencyExtras/Result.swift | 14 + Sources/ConcurrencyExtras/Task.swift | 84 +++ .../ConcurrencyExtras/UncheckedSendable.swift | 110 ++++ .../Internal/AppHostWarning.swift | 79 +++ .../Internal/Deprecations.swift | 12 + .../Internal/FailureObserver.swift | 21 + .../Internal/LockIsolated.swift | 21 + .../IssueReporting/Internal/Rethrows.swift | 17 + .../Internal/SwiftTesting.swift | 500 ++++++++++++++++++ .../Internal/UncheckedSendable.swift | 9 + Sources/IssueReporting/Internal/Warn.swift | 14 + Sources/IssueReporting/Internal/XCTest.swift | 126 +++++ Sources/IssueReporting/IsTesting.swift | 43 ++ Sources/IssueReporting/IssueReporter.swift | 197 +++++++ .../IssueReporters/BreakpointReporter.swift | 52 ++ .../IssueReporters/FatalErrorReporter.swift | 25 + .../RuntimeWarningReporter.swift | 118 +++++ Sources/IssueReporting/ReportIssue.swift | 151 ++++++ Sources/IssueReporting/TestContext.swift | 79 +++ Sources/IssueReporting/Unimplemented.swift | 303 +++++++++++ .../IssueReporting/WithExpectedIssue.swift | 203 +++++++ Sources/IssueReporting/WithIssueContext.swift | 62 +++ .../StateManagement/KlaviyoState.swift | 2 +- Sources/Perception/Environment.swift | 66 +++ Sources/Perception/Internal/AnySendable.swift | 7 + .../Perception/Internal/BetaChecking.swift | 27 + Sources/Perception/Internal/Exports.swift | 8 + Sources/Perception/Internal/Locking.swift | 61 +++ Sources/Perception/Internal/ThreadLocal.swift | 24 + .../Internal/UncheckedSendable.swift | 6 + .../Internal/_PerceptionRegistrar.swift | 377 +++++++++++++ Sources/Perception/Perceptible.swift | 19 + Sources/Perception/PerceptionChecking.swift | 45 ++ Sources/Perception/PerceptionRegistrar.swift | 366 +++++++++++++ Sources/Perception/PerceptionTracking.swift | 223 ++++++++ .../Perception/WithPerceptionTracking.swift | 174 ++++++ .../SharedChangeTracker.swift | 2 +- .../ComposableArchitecture/TestStore.swift | 4 +- 59 files changed, 4204 insertions(+), 46 deletions(-) create mode 100644 Sources/ConcurrencyExtras/ActorIsolated.swift create mode 100644 Sources/ConcurrencyExtras/AnyHashableSendable.swift create mode 100644 Sources/ConcurrencyExtras/AsyncStream.swift create mode 100644 Sources/ConcurrencyExtras/AsyncThrowingStream.swift create mode 100644 Sources/ConcurrencyExtras/Internal/Locking.swift create mode 100644 Sources/ConcurrencyExtras/Internal/UncheckedBox.swift create mode 100644 Sources/ConcurrencyExtras/LockIsolated.swift create mode 100644 Sources/ConcurrencyExtras/MainSerialExecutor.swift create mode 100644 Sources/ConcurrencyExtras/Result.swift create mode 100644 Sources/ConcurrencyExtras/Task.swift create mode 100644 Sources/ConcurrencyExtras/UncheckedSendable.swift create mode 100644 Sources/IssueReporting/Internal/AppHostWarning.swift create mode 100644 Sources/IssueReporting/Internal/Deprecations.swift create mode 100644 Sources/IssueReporting/Internal/FailureObserver.swift create mode 100644 Sources/IssueReporting/Internal/LockIsolated.swift create mode 100644 Sources/IssueReporting/Internal/Rethrows.swift create mode 100644 Sources/IssueReporting/Internal/SwiftTesting.swift create mode 100644 Sources/IssueReporting/Internal/UncheckedSendable.swift create mode 100644 Sources/IssueReporting/Internal/Warn.swift create mode 100644 Sources/IssueReporting/Internal/XCTest.swift create mode 100644 Sources/IssueReporting/IsTesting.swift create mode 100644 Sources/IssueReporting/IssueReporter.swift create mode 100644 Sources/IssueReporting/IssueReporters/BreakpointReporter.swift create mode 100644 Sources/IssueReporting/IssueReporters/FatalErrorReporter.swift create mode 100644 Sources/IssueReporting/IssueReporters/RuntimeWarningReporter.swift create mode 100644 Sources/IssueReporting/ReportIssue.swift create mode 100644 Sources/IssueReporting/TestContext.swift create mode 100644 Sources/IssueReporting/Unimplemented.swift create mode 100644 Sources/IssueReporting/WithExpectedIssue.swift create mode 100644 Sources/IssueReporting/WithIssueContext.swift create mode 100644 Sources/Perception/Environment.swift create mode 100644 Sources/Perception/Internal/AnySendable.swift create mode 100644 Sources/Perception/Internal/BetaChecking.swift create mode 100644 Sources/Perception/Internal/Exports.swift create mode 100644 Sources/Perception/Internal/Locking.swift create mode 100644 Sources/Perception/Internal/ThreadLocal.swift create mode 100644 Sources/Perception/Internal/UncheckedSendable.swift create mode 100644 Sources/Perception/Internal/_PerceptionRegistrar.swift create mode 100644 Sources/Perception/Perceptible.swift create mode 100644 Sources/Perception/PerceptionChecking.swift create mode 100644 Sources/Perception/PerceptionRegistrar.swift create mode 100644 Sources/Perception/PerceptionTracking.swift create mode 100644 Sources/Perception/WithPerceptionTracking.swift diff --git a/.swiftformat b/.swiftformat index 3486c013..8f4cdf74 100644 --- a/.swiftformat +++ b/.swiftformat @@ -10,7 +10,7 @@ # format options ---exclude Sources/KlaviyoSwift/Vendor,Tests/KlaviyoSwiftTests/Vendor,Tests/KlaviyoSwiftTests/__Snapshots__ +--exclude Sources/ConcurrencyExtras,Sources/IssueReporting,Sources/Perception,Sources/IdentifiedCollections,Sources/InternalCollectionsUtilities,Sources/OrderedCollections,Sources/ComposableArchitecture,Sources/KlaviyoSwift/Vendor,Tests/KlaviyoSwiftTests/Vendor,Tests/KlaviyoSwiftTests/__Snapshots__ --closingparen same-line --commas inline --comments indent diff --git a/.swiftlint.yml b/.swiftlint.yml index 65440af3..9441a98c 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -23,6 +23,9 @@ excluded: # paths to ignore during linting. Takes precedence over `included`. - Sources/IdentifiedCollections - Sources/InternalCollectionsUtilities - Sources/OrderedCollections + - Sources/Perception + - Sources/IssueReporting + - Sources/ConcurrencyExtras analyzer_rules: # Rules run by `swiftlint analyze` (experimental) - explicit_self diff --git a/Package.resolved b/Package.resolved index 1fbdc48e..27efad8d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1b089c9aad26faecca73e784aae45fdd744bc3b1d0ab628e73c9a3f9f3e19496", + "originHash" : "8c8cc3892336bc65f365db717e661c18dd83e787da8adb8ec838990b62c6de14", "pins" : [ { "identity" : "combine-schedulers", @@ -37,15 +37,6 @@ "version" : "1.3.3" } }, - { - "identity" : "swift-perception", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-perception", - "state" : { - "revision" : "bc67aa8e461351c97282c2419153757a446ae1c9", - "version" : "1.3.5" - } - }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 3efc50d8..3b5cb852 100644 --- a/Package.swift +++ b/Package.swift @@ -22,11 +22,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.10.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.5.4"), - .package(url: "https://github.com/pointfreeco/swift-perception", from: "1.3.4"), - .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "1.0.2"), - .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.3.0"), - .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.2.0"), - .package(url: "https://github.com/swiftlang/swift-syntax", "509.0.0"..<"601.0.0-prerelease") + .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "1.0.2") ], targets: [ .target( @@ -59,7 +55,8 @@ let package = Package( .product(name: "CasePaths", package: "swift-case-paths"), .product(name: "CombineSchedulers", package: "combine-schedulers"), "KlaviyoCore", - "ComposableArchitecture" + "ComposableArchitecture", + "KIssueReporting" ], exclude: [ "__Snapshots__" @@ -84,12 +81,11 @@ let package = Package( .target( name: "ComposableArchitecture", dependencies: [ - .product(name: "Perception", package: "swift-perception"), - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), .product(name: "CasePaths", package: "swift-case-paths"), .product(name: "CustomDump", package: "swift-custom-dump"), - "IdentifiedCollections" + "IdentifiedCollections", + "KConcurrencyExtras", + "KPerception" ], path: "Sources/ComposableArchitecture"), .target( @@ -107,6 +103,17 @@ let package = Package( .target( name: "AnyCodable", dependencies: [], - path: "Sources/AnyCodable") + path: "Sources/AnyCodable"), + .target( + name: "KConcurrencyExtras", + dependencies: [], + path: "Sources/ConcurrencyExtras"), + .target(name: "KPerception", + dependencies: ["KIssueReporting"], + path: "Sources/Perception"), + .target( + name: "KIssueReporting", + dependencies: [], + path: "Sources/IssueReporting") ]) diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index e4cc3a9e..ece2aebf 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -21,10 +21,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.10.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.5.4"), - .package(url: "https://github.com/pointfreeco/swift-perception", from: "1.3.4"), - .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "1.0.2"), - .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.3.0"), - .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.2.0") + .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "1.0.2") ], targets: [ .target( @@ -57,7 +54,8 @@ let package = Package( .product(name: "CasePaths", package: "swift-case-paths"), .product(name: "CombineSchedulers", package: "combine-schedulers"), "KlaviyoCore", - "ComposableArchitecture" + "ComposableArchitecture", + "KIssueReporting" ], exclude: [ "__Snapshots__" @@ -82,12 +80,11 @@ let package = Package( .target( name: "ComposableArchitecture", dependencies: [ - .product(name: "Perception", package: "swift-perception"), - .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), .product(name: "CasePaths", package: "swift-case-paths"), .product(name: "CustomDump", package: "swift-custom-dump"), - "IdentifiedCollections" + "IdentifiedCollections", + "KConcurrencyExtras", + "KPerception" ], path: "Sources/ComposableArchitecture"), .target( @@ -105,6 +102,17 @@ let package = Package( .target( name: "AnyCodable", dependencies: [], - path: "Sources/AnyCodable") + path: "Sources/AnyCodable"), + .target( + name: "KConcurrencyExtras", + dependencies: [], + path: "Sources/ConcurrencyExtras"), + .target(name: "KPerception", + dependencies: ["KIssueReporting"], + path: "Sources/Perception"), + .target( + name: "KIssueReporting", + dependencies: [], + path: "Sources/IssueReporting") ], swiftLanguageModes: [.v6]) diff --git a/Sources/ComposableArchitecture/Effect.swift b/Sources/ComposableArchitecture/Effect.swift index 2c381caa..7b8b50b6 100644 --- a/Sources/ComposableArchitecture/Effect.swift +++ b/Sources/ComposableArchitecture/Effect.swift @@ -1,5 +1,5 @@ @preconcurrency import Combine -import ConcurrencyExtras +import KConcurrencyExtras import CustomDump import Foundation import IssueReporting @@ -15,7 +15,7 @@ public struct Effect: Sendable { @usableFromInline let operation: Operation - + @usableFromInline init(operation: Operation) { self.operation = operation diff --git a/Sources/ComposableArchitecture/Effects/Cancellation.swift b/Sources/ComposableArchitecture/Effects/Cancellation.swift index f60e5124..160e9d41 100644 --- a/Sources/ComposableArchitecture/Effects/Cancellation.swift +++ b/Sources/ComposableArchitecture/Effects/Cancellation.swift @@ -1,5 +1,5 @@ @preconcurrency import Combine -import ConcurrencyExtras +import KConcurrencyExtras import Foundation extension Effect { diff --git a/Sources/ComposableArchitecture/Internal/Create.swift b/Sources/ComposableArchitecture/Internal/Create.swift index 17aa661d..08396da2 100644 --- a/Sources/ComposableArchitecture/Internal/Create.swift +++ b/Sources/ComposableArchitecture/Internal/Create.swift @@ -21,7 +21,7 @@ // THE SOFTWARE. @preconcurrency import Combine -import ConcurrencyExtras +import KConcurrencyExtras import Darwin final class DemandBuffer: @unchecked Sendable { diff --git a/Sources/ComposableArchitecture/Observation/ObservableState.swift b/Sources/ComposableArchitecture/Observation/ObservableState.swift index a24e8188..322c5384 100644 --- a/Sources/ComposableArchitecture/Observation/ObservableState.swift +++ b/Sources/ComposableArchitecture/Observation/ObservableState.swift @@ -1,6 +1,6 @@ import Foundation import IdentifiedCollections -import Perception +import KPerception /// A type that emits notifications to observers when underlying data changes. /// diff --git a/Sources/ComposableArchitecture/Observation/Observation+State.swift b/Sources/ComposableArchitecture/Observation/Observation+State.swift index 83e74e63..795241a8 100644 --- a/Sources/ComposableArchitecture/Observation/Observation+State.swift +++ b/Sources/ComposableArchitecture/Observation/Observation+State.swift @@ -1,5 +1,5 @@ import CasePaths -import Perception +import KPerception import SwiftUI import XCTestDynamicOverlay diff --git a/Sources/ComposableArchitecture/Observation/ObservationStateRegistrar.swift b/Sources/ComposableArchitecture/Observation/ObservationStateRegistrar.swift index ce6c5c25..dd5f6181 100644 --- a/Sources/ComposableArchitecture/Observation/ObservationStateRegistrar.swift +++ b/Sources/ComposableArchitecture/Observation/ObservationStateRegistrar.swift @@ -1,4 +1,4 @@ -import Perception +import KPerception /// Provides storage for tracking and access to data changes. struct ObservationStateRegistrar: Sendable { diff --git a/Sources/ComposableArchitecture/RootStore.swift b/Sources/ComposableArchitecture/RootStore.swift index 4b4c67c9..41efa799 100644 --- a/Sources/ComposableArchitecture/RootStore.swift +++ b/Sources/ComposableArchitecture/RootStore.swift @@ -1,8 +1,8 @@ import Combine -import ConcurrencyExtras +import KConcurrencyExtras import CustomDump import Foundation -import Perception +import KPerception import XCTestDynamicOverlay @_spi(Internals) diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index a4c88304..7e9338fe 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -1,7 +1,7 @@ import CasePaths import Combine import Foundation -import Perception +import KPerception import SwiftUI /// A store represents the runtime that powers the application. It is the object that you will pass diff --git a/Sources/ConcurrencyExtras/ActorIsolated.swift b/Sources/ConcurrencyExtras/ActorIsolated.swift new file mode 100644 index 00000000..ff3531d6 --- /dev/null +++ b/Sources/ConcurrencyExtras/ActorIsolated.swift @@ -0,0 +1,113 @@ +/// A generic wrapper for isolating a mutable value to an actor. +/// +/// This type is most useful when writing tests for when you want to inspect what happens inside an +/// async operation. +/// +/// For example, suppose you have a feature such that when a button is tapped you track some +/// analytics: +/// +/// ```swift +/// struct AnalyticsClient { +/// var track: (String) async -> Void +/// } +/// +/// class FeatureModel: ObservableObject { +/// let analytics: AnalyticsClient +/// // ... +/// func buttonTapped() { +/// // ... +/// await self.analytics.track("Button tapped") +/// } +/// } +/// ``` +/// +/// Then, in tests we can construct an analytics client that appends events to a mutable array +/// rather than actually sending events to an analytics server. However, in order to do this in a +/// safe way we should use an actor, and `ActorIsolated` makes this easy: +/// +/// ```swift +/// func testAnalytics() async { +/// let events = ActorIsolated<[String]>([]) +/// let analytics = AnalyticsClient( +/// track: { event in await events.withValue { $0.append(event) } } +/// ) +/// let model = FeatureModel(analytics: analytics) +/// model.buttonTapped() +/// await events.withValue { +/// XCTAssertEqual($0, ["Button tapped"]) +/// } +/// } +/// ``` +/// +/// To synchronously isolate a value, see ``LockIsolated``. +@available(*, deprecated, message: "Use 'LockIsolated' instead.") +public final actor ActorIsolated { + /// The actor-isolated value. + public var value: Value + + /// Initializes actor-isolated state around a value. + /// + /// - Parameter value: A value to isolate in an actor. + public init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { + self.value = try value() + } + + /// Perform an operation with isolated access to the underlying value. + /// + /// Useful for modifying a value in a single transaction. + /// + /// ```swift + /// // Isolate an integer for concurrent read/write access: + /// let count = ActorIsolated(0) + /// + /// func increment() async { + /// // Safely increment it: + /// await self.count.withValue { $0 += 1 } + /// } + /// ``` + /// + /// > Tip: Because XCTest assertions don't play nicely with Swift concurrency, `withValue` also + /// > provides a handy interface to peek at an actor-isolated value and assert against it: + /// > + /// > ```swift + /// > let didOpenSettings = ActorIsolated(false) + /// > let model = withDependencies { + /// > $0.openSettings = { await didOpenSettings.setValue(true) } + /// > } operation: { + /// > FeatureModel() + /// > } + /// > await model.settingsButtonTapped() + /// > await didOpenSettings.withValue { XCTAssertTrue($0) } + /// > ``` + /// + /// - Parameter operation: An operation to be performed on the actor with the underlying value. + /// - Returns: The result of the operation. + public func withValue( + _ operation: @Sendable (inout Value) throws -> T + ) rethrows -> T { + var value = self.value + defer { self.value = value } + return try operation(&value) + } + + /// Overwrite the isolated value with a new value. + /// + /// ```swift + /// // Isolate an integer for concurrent read/write access: + /// let count = ActorIsolated(0) + /// + /// func reset() async { + /// // Reset it: + /// await self.count.setValue(0) + /// } + /// ``` + /// + /// > Tip: Use ``withValue(_:)`` instead of `setValue` if the value being set is derived from the + /// > current value. This isolates the entire transaction and avoids data races between reading + /// > and writing the value. + /// + /// - Parameter newValue: The value to replace the current isolated value with. + public func setValue(_ newValue: @autoclosure @Sendable () throws -> Value) rethrows { + self.value = try newValue() + } +} diff --git a/Sources/ConcurrencyExtras/AnyHashableSendable.swift b/Sources/ConcurrencyExtras/AnyHashableSendable.swift new file mode 100644 index 00000000..e4a510be --- /dev/null +++ b/Sources/ConcurrencyExtras/AnyHashableSendable.swift @@ -0,0 +1,42 @@ +/// A type-erased hashable, sendable value. +/// +/// A sendable version of `AnyHashable` that is useful in working around the limitation that an +/// existential `any Hashable` does not conform to `Hashable`. +public struct AnyHashableSendable: Hashable, Sendable { + public let base: any Hashable & Sendable + + /// Creates a type-erased hashable, sendable value that wraps the given instance. + public init(_ base: some Hashable & Sendable) { + if let base = base as? AnyHashableSendable { + self = base + } else { + self.base = base + } + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + AnyHashable(lhs.base) == AnyHashable(rhs.base) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(base) + } +} + +extension AnyHashableSendable: CustomDebugStringConvertible { + public var debugDescription: String { + "AnyHashableSendable(" + String(reflecting: base) + ")" + } +} + +extension AnyHashableSendable: CustomReflectable { + public var customMirror: Mirror { + Mirror(self, children: ["value": base]) + } +} + +extension AnyHashableSendable: CustomStringConvertible { + public var description: String { + String(describing: base) + } +} diff --git a/Sources/ConcurrencyExtras/AsyncStream.swift b/Sources/ConcurrencyExtras/AsyncStream.swift new file mode 100644 index 00000000..32690166 --- /dev/null +++ b/Sources/ConcurrencyExtras/AsyncStream.swift @@ -0,0 +1,85 @@ +import Foundation + +extension AsyncStream { + /// Produces an `AsyncStream` from an `AsyncSequence` by consuming the sequence till it + /// terminates, ignoring any failure. + /// + /// Useful as a kind of type eraser for live `AsyncSequence`-based dependencies. + /// + /// For example, your feature may want to subscribe to screenshot notifications. You can model + /// this as a dependency client that returns an `AsyncStream`: + /// + /// ```swift + /// struct ScreenshotsClient { + /// var screenshots: () -> AsyncStream + /// func callAsFunction() -> AsyncStream { self.screenshots() } + /// } + /// ``` + /// + /// The "live" implementation of the dependency can supply a stream by erasing the appropriate + /// `NotificationCenter.Notifications` async sequence: + /// + /// ```swift + /// extension ScreenshotsClient { + /// static let live = Self( + /// screenshots: { + /// AsyncStream( + /// NotificationCenter.default + /// .notifications(named: UIApplication.userDidTakeScreenshotNotification) + /// .map { _ in } + /// ) + /// } + /// ) + /// } + /// ``` + /// + /// While your tests can use `AsyncStream.makeStream` to spin up a controllable stream for tests: + /// + /// ```swift + /// func testScreenshots() { + /// let screenshots = AsyncStream.makeStream(of: Void.self) + /// + /// let model = withDependencies { + /// $0.screenshots = { screenshots.stream } + /// } operation: { + /// FeatureModel() + /// } + /// + /// XCTAssertEqual(model.screenshotCount, 0) + /// screenshots.continuation.yield() // Simulate a screenshot being taken. + /// XCTAssertEqual(model.screenshotCount, 1) + /// } + /// ``` + /// + /// - Parameter sequence: An async sequence. + public init(_ sequence: S) where S.Element == Element, S: Sendable { + let lock = NSLock() + let iterator = UncheckedBox(wrappedValue: nil) + self.init { + lock.withLock { + if iterator.wrappedValue == nil { + iterator.wrappedValue = sequence.makeAsyncIterator() + } + } + return try? await iterator.wrappedValue?.next() + } + } + + /// An `AsyncStream` that never emits and never completes unless cancelled. + public static var never: Self { + Self { _ in } + } + + /// An `AsyncStream` that never emits and completes immediately. + public static var finished: Self { + Self { $0.finish() } + } +} + +extension AsyncSequence { + /// Erases this async sequence to an async stream that produces elements till this sequence + /// terminates (or fails). + public func eraseToStream() -> AsyncStream where Self: Sendable { + AsyncStream(self) + } +} diff --git a/Sources/ConcurrencyExtras/AsyncThrowingStream.swift b/Sources/ConcurrencyExtras/AsyncThrowingStream.swift new file mode 100644 index 00000000..5c6141bb --- /dev/null +++ b/Sources/ConcurrencyExtras/AsyncThrowingStream.swift @@ -0,0 +1,40 @@ +import Foundation + +extension AsyncThrowingStream where Failure == Error { + /// Produces an `AsyncThrowingStream` from an `AsyncSequence` by consuming the sequence till it + /// terminates, rethrowing any failure. + /// + /// - Parameter sequence: An async sequence. + public init(_ sequence: S) where S.Element == Element, S: Sendable { + let lock = NSLock() + let iterator = UncheckedBox(wrappedValue: nil) + self.init { + lock.withLock { + if iterator.wrappedValue == nil { + iterator.wrappedValue = sequence.makeAsyncIterator() + } + } + return try await iterator.wrappedValue?.next() + } + } + + /// An `AsyncThrowingStream` that never emits and never completes unless cancelled. + public static var never: Self { + Self { _ in } + } + + /// An `AsyncThrowingStream` that completes immediately. + /// + /// - Parameter error: An optional error the stream completes with. + public static func finished(throwing error: Failure? = nil) -> Self { + Self { $0.finish(throwing: error) } + } +} + +extension AsyncSequence { + /// Erases this async sequence to an async throwing stream that produces elements till this + /// sequence terminates, rethrowing any error on failure. + public func eraseToThrowingStream() -> AsyncThrowingStream where Self: Sendable { + AsyncThrowingStream(self) + } +} diff --git a/Sources/ConcurrencyExtras/Internal/Locking.swift b/Sources/ConcurrencyExtras/Internal/Locking.swift new file mode 100644 index 00000000..a97a9d0f --- /dev/null +++ b/Sources/ConcurrencyExtras/Internal/Locking.swift @@ -0,0 +1,11 @@ +import Foundation + +#if !(os(iOS) || os(macOS) || os(tvOS) || os(watchOS)) + extension NSLock { + func withLock(_ body: () throws -> R) rethrows -> R { + self.lock() + defer { self.unlock() } + return try body() + } + } +#endif diff --git a/Sources/ConcurrencyExtras/Internal/UncheckedBox.swift b/Sources/ConcurrencyExtras/Internal/UncheckedBox.swift new file mode 100644 index 00000000..dbe34bd6 --- /dev/null +++ b/Sources/ConcurrencyExtras/Internal/UncheckedBox.swift @@ -0,0 +1,6 @@ +final class UncheckedBox: @unchecked Sendable { + var wrappedValue: Value + init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + } +} diff --git a/Sources/ConcurrencyExtras/LockIsolated.swift b/Sources/ConcurrencyExtras/LockIsolated.swift new file mode 100644 index 00000000..640d88ce --- /dev/null +++ b/Sources/ConcurrencyExtras/LockIsolated.swift @@ -0,0 +1,119 @@ +import Foundation + +/// A generic wrapper for isolating a mutable value with a lock. +/// +/// If you trust the sendability of the underlying value, consider using ``UncheckedSendable``, +/// instead. +@dynamicMemberLookup +public final class LockIsolated: @unchecked Sendable { + private var _value: Value + private let lock = NSRecursiveLock() + + /// Initializes lock-isolated state around a value. + /// + /// - Parameter value: A value to isolate with a lock. + public init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { + self._value = try value() + } + + public subscript(dynamicMember keyPath: KeyPath) -> Subject { + self.lock.sync { + self._value[keyPath: keyPath] + } + } + + /// Perform an operation with isolated access to the underlying value. + /// + /// Useful for modifying a value in a single transaction. + /// + /// ```swift + /// // Isolate an integer for concurrent read/write access: + /// var count = LockIsolated(0) + /// + /// func increment() { + /// // Safely increment it: + /// self.count.withValue { $0 += 1 } + /// } + /// ``` + /// + /// - Parameter operation: An operation to be performed on the the underlying value with a lock. + /// - Returns: The result of the operation. + public func withValue( + _ operation: @Sendable (inout Value) throws -> T + ) rethrows -> T { + try self.lock.sync { + var value = self._value + defer { self._value = value } + return try operation(&value) + } + } + + /// Overwrite the isolated value with a new value. + /// + /// ```swift + /// // Isolate an integer for concurrent read/write access: + /// var count = LockIsolated(0) + /// + /// func reset() { + /// // Reset it: + /// self.count.setValue(0) + /// } + /// ``` + /// + /// > Tip: Use ``withValue(_:)`` instead of ``setValue(_:)`` if the value being set is derived + /// > from the current value. That is, do this: + /// > + /// > ```swift + /// > self.count.withValue { $0 += 1 } + /// > ``` + /// > + /// > ...and not this: + /// > + /// > ```swift + /// > self.count.setValue(self.count + 1) + /// > ``` + /// > + /// > ``withValue(_:)`` isolates the entire transaction and avoids data races between reading and + /// > writing the value. + /// + /// - Parameter newValue: The value to replace the current isolated value with. + public func setValue(_ newValue: @autoclosure @Sendable () throws -> Value) rethrows { + try self.lock.sync { + self._value = try newValue() + } + } +} + +extension LockIsolated where Value: Sendable { + /// The lock-isolated value. + public var value: Value { + self.lock.sync { + self._value + } + } +} + +#if swift(<6) + @available(*, deprecated, message: "Lock isolated values should not be equatable") + extension LockIsolated: Equatable where Value: Equatable { + public static func == (lhs: LockIsolated, rhs: LockIsolated) -> Bool { + lhs.value == rhs.value + } + } + + @available(*, deprecated, message: "Lock isolated values should not be hashable") + extension LockIsolated: Hashable where Value: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(self.value) + } + } +#endif + +extension NSRecursiveLock { + @inlinable @discardableResult + @_spi(Internals) public func sync(work: () throws -> R) rethrows -> R { + self.lock() + defer { self.unlock() } + return try work() + } +} diff --git a/Sources/ConcurrencyExtras/MainSerialExecutor.swift b/Sources/ConcurrencyExtras/MainSerialExecutor.swift new file mode 100644 index 00000000..e37ce11c --- /dev/null +++ b/Sources/ConcurrencyExtras/MainSerialExecutor.swift @@ -0,0 +1,90 @@ +#if !os(WASI) && !os(Windows) + import Foundation + + /// Perform an operation on the main serial executor. + /// + /// Some asynchronous code is [notoriously + /// difficult](https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304) + /// to test in Swift due to how suspension points are processed by the runtime. This function + /// attempts to run all tasks spawned in the given operation serially and deterministically. It + /// makes asynchronous tests faster and less flakey. + /// + /// ```swift + /// await withMainSerialExecutor { + /// // Everything performed in this scope is performed serially... + /// } + /// ``` + /// + /// See for more information on why this tool is needed to test + /// async code and how to use it. + /// + /// > Warning: This API is only intended to be used from tests to make them more reliable. Please do + /// > not use it from application code. + /// > + /// > We say that it "_attempts_ to run all tasks spawned in an operation serially and + /// > deterministically" because under the hood it relies on a global, mutable variable in the Swift + /// > runtime to do its job, and there are no scoping _guarantees_ should this mutable variable change + /// > during the operation. + /// + /// - Parameter operation: An operation to be performed on the main serial executor. + @MainActor + public func withMainSerialExecutor( + @_implicitSelfCapture operation: @Sendable () async throws -> Void + ) async rethrows { + let didUseMainSerialExecutor = uncheckedUseMainSerialExecutor + defer { uncheckedUseMainSerialExecutor = didUseMainSerialExecutor } + uncheckedUseMainSerialExecutor = true + try await operation() + } + + /// Perform an operation on the main serial executor. + /// + /// A synchronous version of ``withMainSerialExecutor(operation:)-79jpc`` that can be used in + /// `XCTestCase.invokeTest` to ensure all async tests are performed serially: + /// + /// ```swift + /// class BaseTestCase: XCTestCase { + /// override func invokeTest() { + /// withMainSerialExecutor { + /// super.invokeTest() + /// } + /// } + /// } + /// ``` + /// + /// - Parameter operation: An operation to be performed on the main serial executor. + public func withMainSerialExecutor( + @_implicitSelfCapture operation: () throws -> Void + ) rethrows { + let didUseMainSerialExecutor = uncheckedUseMainSerialExecutor + defer { uncheckedUseMainSerialExecutor = didUseMainSerialExecutor } + uncheckedUseMainSerialExecutor = true + try operation() + } + + /// Overrides Swift's global executor with the main serial executor in an unchecked fashion. + /// + /// > Warning: When set to `true`, all tasks will be enqueued on the main serial executor till set + /// > back to `false`. Consider using ``withMainSerialExecutor(operation:)-79jpc``, instead, which + /// > scopes this work to the duration of a given operation. + public var uncheckedUseMainSerialExecutor: Bool { + get { swift_task_enqueueGlobal_hook != nil } + set { + swift_task_enqueueGlobal_hook = + newValue + ? { job, _ in MainActor.shared.enqueue(job) } + : nil + } + } + + private typealias Original = @convention(thin) (UnownedJob) -> Void + private typealias Hook = @convention(thin) (UnownedJob, Original) -> Void + + private var swift_task_enqueueGlobal_hook: Hook? { + get { _swift_task_enqueueGlobal_hook.wrappedValue.pointee } + set { _swift_task_enqueueGlobal_hook.wrappedValue.pointee = newValue } + } + private let _swift_task_enqueueGlobal_hook = UncheckedSendable( + dlsym(dlopen(nil, 0), "swift_task_enqueueGlobal_hook").assumingMemoryBound(to: Hook?.self) + ) +#endif diff --git a/Sources/ConcurrencyExtras/Result.swift b/Sources/ConcurrencyExtras/Result.swift new file mode 100644 index 00000000..046066b8 --- /dev/null +++ b/Sources/ConcurrencyExtras/Result.swift @@ -0,0 +1,14 @@ +extension Result where Failure == Swift.Error { + /// Creates a new result by evaluating an async throwing closure, capturing the returned value as + /// a success, or any thrown error as a failure. + /// + /// - Parameter body: A throwing closure to evaluate. + @_transparent + public init(catching body: () async throws -> Success) async { + do { + self = .success(try await body()) + } catch { + self = .failure(error) + } + } +} diff --git a/Sources/ConcurrencyExtras/Task.swift b/Sources/ConcurrencyExtras/Task.swift new file mode 100644 index 00000000..4be0a3fd --- /dev/null +++ b/Sources/ConcurrencyExtras/Task.swift @@ -0,0 +1,84 @@ +import Foundation + +extension Task where Success == Never, Failure == Never { + /// Suspends the current task a number of times before resuming with the goal of allowing other + /// tasks to start their work. + /// + /// This function can be used to make flakey async tests less flakey, as described in + /// [this Swift Forums post](https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304). + /// You may, however, prefer to use ``withMainSerialExecutor(operation:)-79jpc`` to improve the + /// reliability of async tests, and to make their execution deterministic. + /// + /// > Note: When invoked from ``withMainSerialExecutor(operation:)-79jpc``, or when + /// > ``uncheckedUseMainSerialExecutor`` is set to `true`, `Task.megaYield()` is equivalent to + /// > a single `Task.yield()`. + public static func megaYield(count: Int = _defaultMegaYieldCount) async { + // TODO: Investigate why mega yields are still necessary in TCA's test suite. + // guard !uncheckedUseMainSerialExecutor else { + // await Task.yield() + // return + // } + for _ in 0...detached(priority: .background) { await Task.yield() }.value + } + } +} + +/// The number of yields `Task.megaYield()` invokes by default. +/// +/// Can be overridden by setting the `TASK_MEGA_YIELD_COUNT` environment variable. +public let _defaultMegaYieldCount = max( + 0, + min( + ProcessInfo.processInfo.environment["TASK_MEGA_YIELD_COUNT"].flatMap(Int.init) ?? 20, + 10_000 + ) +) + +extension Task where Failure == Never { + /// An async function that never returns. + public static func never() async throws -> Success { + for await element in AsyncStream.never { + return element + } + throw _Concurrency.CancellationError() + } +} + +extension Task where Success == Never, Failure == Never { + /// An async function that never returns. + public static func never() async throws { + for await _ in AsyncStream.never {} + throw _Concurrency.CancellationError() + } +} + +extension Task where Failure == Never { + /// Waits for the result of the task, propagating cancellation to the task. + /// + /// Equivalent to wrapping a call to `Task.value` in `withTaskCancellationHandler`. + public var cancellableValue: Success { + get async { + await withTaskCancellationHandler { + await self.value + } onCancel: { + self.cancel() + } + } + } +} + +extension Task where Failure == Error { + /// Waits for the result of the task, propagating cancellation to the task. + /// + /// Equivalent to wrapping a call to `Task.value` in `withTaskCancellationHandler`. + public var cancellableValue: Success { + get async throws { + try await withTaskCancellationHandler { + try await self.value + } onCancel: { + self.cancel() + } + } + } +} diff --git a/Sources/ConcurrencyExtras/UncheckedSendable.swift b/Sources/ConcurrencyExtras/UncheckedSendable.swift new file mode 100644 index 00000000..2747c9dd --- /dev/null +++ b/Sources/ConcurrencyExtras/UncheckedSendable.swift @@ -0,0 +1,110 @@ +/// A generic wrapper for turning any non-`Sendable` type into a `Sendable` one, in an unchecked +/// manner. +/// +/// Sometimes we need to use types that should be sendable but have not yet been audited for +/// sendability. If we feel confident that the type is truly sendable, and we don't want to blanket +/// disable concurrency warnings for a module via `@preconcurrency import`, then we can selectively +/// make that single type sendable by wrapping it in `UncheckedSendable`. +/// +/// > Note: By wrapping something in `UncheckedSendable` you are asking the compiler to trust you +/// > that the type is safe to use from multiple threads, and the compiler cannot help you find +/// > potential race conditions in your code. +/// +/// To synchronously isolate a value with a lock, see ``LockIsolated``. +#if swift(>=5.10) + @available(iOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + macOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + )@available(tvOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + watchOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + ) +#endif +@dynamicMemberLookup +@propertyWrapper +public struct UncheckedSendable: @unchecked Sendable { + /// The unchecked value. + public var value: Value + + /// Initializes unchecked sendability around a value. + /// + /// - Parameter value: A value to make sendable in an unchecked way. + public init(_ value: Value) { + self.value = value + } + + public init(wrappedValue: Value) { + self.value = wrappedValue + } + + public var wrappedValue: Value { + _read { yield self.value } + _modify { yield &self.value } + } + + public var projectedValue: Self { + get { self } + set { self = newValue } + } + + public subscript(dynamicMember keyPath: KeyPath) -> Subject { + self.value[keyPath: keyPath] + } + + public subscript(dynamicMember keyPath: WritableKeyPath) -> Subject { + _read { yield self.value[keyPath: keyPath] } + _modify { yield &self.value[keyPath: keyPath] } + } +} + +#if swift(>=5.10) + @available(iOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + macOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + )@available(tvOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + watchOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + ) +#endif +extension UncheckedSendable: Equatable where Value: Equatable {} + +#if swift(>=5.10) + @available(iOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + macOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + )@available(tvOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + watchOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + ) +#endif +extension UncheckedSendable: Hashable where Value: Hashable {} + +#if swift(>=5.10) + @available(iOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + macOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + )@available(tvOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + watchOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + ) +#endif +extension UncheckedSendable: Decodable where Value: Decodable { + public init(from decoder: Decoder) throws { + do { + let container = try decoder.singleValueContainer() + self.init(wrappedValue: try container.decode(Value.self)) + } catch { + self.init(wrappedValue: try Value(from: decoder)) + } + } +} + +#if swift(>=5.10) + @available(iOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + macOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + )@available(tvOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead.")@available( + watchOS, deprecated: 9999, message: "Use 'nonisolated(unsafe) let', instead." + ) +#endif +extension UncheckedSendable: Encodable where Value: Encodable { + public func encode(to encoder: Encoder) throws { + do { + var container = encoder.singleValueContainer() + try container.encode(self.wrappedValue) + } catch { + try self.wrappedValue.encode(to: encoder) + } + } +} diff --git a/Sources/IssueReporting/Internal/AppHostWarning.swift b/Sources/IssueReporting/Internal/AppHostWarning.swift new file mode 100644 index 00000000..941f9af8 --- /dev/null +++ b/Sources/IssueReporting/Internal/AppHostWarning.swift @@ -0,0 +1,79 @@ +import Foundation + +extension String? { + @usableFromInline + func withAppHostWarningIfNeeded() -> String? { + guard let self + else { + let warning = "".withAppHostWarningIfNeeded() + return warning.isEmpty ? nil : warning + } + return self.withAppHostWarningIfNeeded() + } +} + +extension String { + @usableFromInline + func withAppHostWarningIfNeeded() -> String { + #if os(WASI) + return self + #else + guard + isTesting, + Bundle.main.bundleIdentifier != "com.apple.dt.xctest.tool" + else { return self } + + let callStack = Thread.callStackSymbols + guard + callStack.allSatisfy({ !$0.contains(" XCTestCore ") }), + callStack.allSatisfy({ !$0.isTestFrame }) + else { return self } + + let warning = """ + This issue was emitted from tests running in a host application\ + \(Bundle.main.bundleIdentifier.map { " (\($0))" } ?? ""). + + This can lead to false positives, where failures could have emitted from live application \ + code at launch time, and not from the current test. + + For more information (and workarounds), see "Testing gotchas": + + https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies/testing#Testing-gotchas + """ + + return isEmpty + ? warning + : """ + \(self) + + ━━┉┅ + \(warning) + """ + #endif + } + + #if !os(WASI) + @usableFromInline + var isTestFrame: Bool { + guard let xcTestCase = NSClassFromString("XCTestCase") + else { return false } + + // Regular expression to detect and demangle an XCTest case frame: + // + // 1. `(?<=\$s)`: Starts with "$s" (stable mangling) + // 2. `\d{1,3}`: Some numbers (the class name length or the module name length) + // 3. `.*`: The class name, or module name + class name length + class name + // 4. `C`: The class type identifier + // 5. `(?=\d{1,3}test.*yy(Ya)?K?F)`: The function name length, a function that starts with + // `test`, has no arguments (`y`), returns Void (`y`), and is a function (`F`), + // potentially async (`Ya`), throwing (`K`), or both. + return range( + of: #"(?<=\$s)\d{1,3}.*C(?=\d{1,3}test.*yy(Ya)?K?F)"#, options: .regularExpression + ) + .map { + (_typeByName(String(self[$0])) as? NSObject.Type)?.isSubclass(of: xcTestCase) ?? false + } + ?? false + } + #endif +} diff --git a/Sources/IssueReporting/Internal/Deprecations.swift b/Sources/IssueReporting/Internal/Deprecations.swift new file mode 100644 index 00000000..11aa07f3 --- /dev/null +++ b/Sources/IssueReporting/Internal/Deprecations.swift @@ -0,0 +1,12 @@ +// NB: Deprecated after 1.2.2 + +#if canImport(Darwin) + @available(*, unavailable, renamed: "_BreakpointReporter") + public typealias BreakpointReporter = _BreakpointReporter +#endif + +@available(*, unavailable, renamed: "_FatalErrorReporter") +public typealias FatalErrorReporter = _FatalErrorReporter + +@available(*, unavailable, renamed: "_RuntimeWarningReporter") +public typealias RuntimeWarningReporter = _RuntimeWarningReporter diff --git a/Sources/IssueReporting/Internal/FailureObserver.swift b/Sources/IssueReporting/Internal/FailureObserver.swift new file mode 100644 index 00000000..eaa633d0 --- /dev/null +++ b/Sources/IssueReporting/Internal/FailureObserver.swift @@ -0,0 +1,21 @@ +import Foundation + +@usableFromInline +final class FailureObserver: @unchecked Sendable { + @TaskLocal public static var current: FailureObserver? + + private let lock = NSRecursiveLock() + private var count = 0 + + @usableFromInline + init(count: Int = 0) { + self.count = count + } + + @usableFromInline + func withLock(_ body: (inout Int) -> R) -> R { + lock.lock() + defer { lock.unlock() } + return body(&count) + } +} diff --git a/Sources/IssueReporting/Internal/LockIsolated.swift b/Sources/IssueReporting/Internal/LockIsolated.swift new file mode 100644 index 00000000..59e56606 --- /dev/null +++ b/Sources/IssueReporting/Internal/LockIsolated.swift @@ -0,0 +1,21 @@ +import Foundation + +@usableFromInline +final class LockIsolated: @unchecked Sendable { + private var _value: Value + private let lock = NSRecursiveLock() + @usableFromInline + init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { + self._value = try value() + } + @usableFromInline + func withLock( + _ operation: @Sendable (inout Value) throws -> T + ) rethrows -> T { + lock.lock() + defer { lock.unlock() } + var value = _value + defer { _value = value } + return try operation(&value) + } +} diff --git a/Sources/IssueReporting/Internal/Rethrows.swift b/Sources/IssueReporting/Internal/Rethrows.swift new file mode 100644 index 00000000..58e6626f --- /dev/null +++ b/Sources/IssueReporting/Internal/Rethrows.swift @@ -0,0 +1,17 @@ +@rethrows +@usableFromInline +protocol _ErrorMechanism { + associatedtype Output + func get() throws -> Output +} +extension _ErrorMechanism { + func _rethrowError() rethrows -> Never { + _ = try _rethrowGet() + fatalError() + } + @usableFromInline + func _rethrowGet() rethrows -> Output { + return try get() + } +} +extension Result: _ErrorMechanism {} diff --git a/Sources/IssueReporting/Internal/SwiftTesting.swift b/Sources/IssueReporting/Internal/SwiftTesting.swift new file mode 100644 index 00000000..c43b2f96 --- /dev/null +++ b/Sources/IssueReporting/Internal/SwiftTesting.swift @@ -0,0 +1,500 @@ +import Foundation + +#if canImport(WinSDK) + import WinSDK +#endif + +@usableFromInline +func _recordIssue( + message: String?, + fileID: String = #fileID, + filePath: String = #filePath, + line: Int = #line, + column: Int = #column +) { + guard let function = function(for: "$s25IssueReportingTestSupport07_recordA0ypyF") + else { + #if DEBUG + guard + let record = unsafeBitCast( + symbol: "$s7Testing5IssueV6record_14sourceLocationAcA7CommentVSg_AA06SourceE0VtFZ", + in: "Testing", + to: (@convention(thin) (Any?, SourceLocation) -> Issue).self + ) + else { return } + + var comment: Any? + if let message { + var c = UnsafeMutablePointer.allocate(capacity: 1).pointee + c.rawValue = message + comment = c + } + _ = record( + comment, + SourceLocation(fileID: fileID, _filePath: filePath, line: line, column: column) + ) + #else + printError( + """ + \(fileID):\(line): An issue was recorded without linking the Testing framework. + + To fix this, add "IssueReportingTestSupport" as a dependency to your test target. + """ + ) + #endif + return + } + + let recordIssue = function as! @Sendable (String?, String, String, Int, Int) -> Void + recordIssue(message, fileID, filePath, line, column) +} + +@usableFromInline +func _recordError( + error: any Error, + message: String?, + fileID: String = #fileID, + filePath: String = #filePath, + line: Int = #line, + column: Int = #column +) { + guard let function = function(for: "$s25IssueReportingTestSupport12_recordErrorypyF") + else { + #if DEBUG + guard + let record = unsafeBitCast( + symbol: """ + $s7Testing5IssueV6record__14sourceLocationACs5Error_p_AA7CommentVSgAA06SourceE0VtFZ + """, + in: "Testing", + to: (@convention(thin) (any Error, Any?, SourceLocation) -> Issue).self + ) + else { return } + + var comment: Any? + if let message { + var c = UnsafeMutablePointer.allocate(capacity: 1).pointee + c.rawValue = message + comment = c + } + _ = record( + error, + comment, + SourceLocation(fileID: fileID, _filePath: filePath, line: line, column: column) + ) + #else + printError( + """ + \(fileID):\(line): An issue was recorded without linking the Testing framework. + + To fix this, add "IssueReportingTestSupport" as a dependency to your test target. + """ + ) + #endif + return + } + + let recordError = function as! @Sendable (any Error, String?, String, String, Int, Int) -> Void + recordError(error, message, fileID, filePath, line, column) +} + +@usableFromInline +func _withKnownIssue( + _ message: String? = nil, + isIntermittent: Bool = false, + fileID: String = #fileID, + filePath: String = #filePath, + line: Int = #line, + column: Int = #column, + _ body: () throws -> Void +) { + guard let function = function(for: "$s25IssueReportingTestSupport010_withKnownA0ypyF") + else { + #if DEBUG + guard + let withKnownIssue = unsafeBitCast( + symbol: """ + $s7Testing14withKnownIssue_14isIntermittent14sourceLocation_yAA7CommentVSg_SbAA06Source\ + H0VyyKXEtF + """, + in: "Testing", + to: (@convention(thin) ( + Any?, + Bool, + SourceLocation, + () throws -> Void + ) -> Void) + .self + ) + else { return } + + var comment: Any? + if let message { + var c = UnsafeMutablePointer.allocate(capacity: 1).pointee + c.rawValue = message + comment = c + } + withKnownIssue( + comment, + isIntermittent, + SourceLocation(fileID: fileID, _filePath: filePath, line: line, column: column), + body + ) + #else + printError( + """ + \(fileID):\(line): A known issue was recorded without linking the Testing framework. + + To fix this, add "IssueReportingTestSupport" as a dependency to your test target. + """ + ) + #endif + return + } + + let withKnownIssue = + function + as! @Sendable ( + String?, + Bool, + String, + String, + Int, + Int, + () throws -> Void + ) -> Void + withKnownIssue(message, isIntermittent, fileID, filePath, line, column, body) +} + +@usableFromInline +func _withKnownIssue( + _ message: String? = nil, + isIntermittent: Bool = false, + fileID: String = #fileID, + filePath: String = #filePath, + line: Int = #line, + column: Int = #column, + _ body: () async throws -> Void +) async { + guard let function = function(for: "$s25IssueReportingTestSupport010_withKnownA5AsyncypyF") + else { + #if DEBUG + guard + let withKnownIssue = unsafeBitCast( + symbol: """ + $s7Testing14withKnownIssue_14isIntermittent14sourceLocation_yAA7CommentVSg_SbAA06Source\ + H0VyyYaKXEtYaFTu + """, + in: "Testing", + to: (@convention(thin) ( + Any?, + Bool, + SourceLocation, + () async throws -> Void + ) async -> Void) + .self + ) + else { return } + + var comment: Any? + if let message { + var c = UnsafeMutablePointer.allocate(capacity: 1).pointee + c.rawValue = message + comment = c + } + await withKnownIssue( + comment, + isIntermittent, + SourceLocation(fileID: fileID, _filePath: filePath, line: line, column: column), + body + ) + #else + printError( + """ + \(fileID):\(line): A known issue was recorded without linking the Testing framework. + + To fix this, add "IssueReportingTestSupport" as a dependency to your test target. + """ + ) + #endif + return + } + + let withKnownIssue = + function + as! @Sendable ( + String?, + Bool, + String, + String, + Int, + Int, + () async throws -> Void + ) async -> Void + await withKnownIssue(message, isIntermittent, fileID, filePath, line, column, body) +} +@usableFromInline +func _currentTestID() -> AnyHashable? { + guard let function = function(for: "$s25IssueReportingTestSupport08_currentC2IDypyF") + else { + #if DEBUG + return Test.current?.id + #else + return nil + #endif + } + + return (function as! @Sendable () -> AnyHashable?)() +} + +#if DEBUG + #if _runtime(_ObjC) + import ObjectiveC + + private typealias __XCTestCompatibleSelector = Selector + #else + private typealias __XCTestCompatibleSelector = Never + #endif + + private struct __Expression: Sendable { + enum Kind: Sendable { + case generic(_ sourceCode: String) + case stringLiteral(sourceCode: String, stringValue: String) + indirect case binaryOperation(lhs: __Expression, `operator`: String, rhs: __Expression) + struct FunctionCallArgument: Sendable { + var label: String? + var value: __Expression + } + indirect case functionCall( + value: __Expression?, functionName: String, arguments: [FunctionCallArgument] + ) + indirect case propertyAccess(value: __Expression, keyPath: __Expression) + indirect case negation(_ expression: __Expression, isParenthetical: Bool) + } + var kind: Kind + struct Value: Sendable { + var description: String + var debugDescription: String + var typeInfo: TypeInfo + var label: String? + var isCollection: Bool + var children: [Self]? + } + var runtimeValue: Value? + } + + private struct Backtrace: Sendable { + typealias Address = UInt64 + var addresses: [Address] + } + + private struct Comment: RawRepresentable, Sendable { + var rawValue: String + init(rawValue: String) { + self.rawValue = rawValue + self.kind = nil + } + enum Kind: Sendable { + case line + case block + case documentationLine + case documentationBlock + case trait + case stringLiteral + } + var kind: Kind? + } + + private struct Confirmation: Sendable { + } + private protocol ExpectedCount: Sendable, RangeExpression {} + + private struct Expectation: Sendable { + var evaluatedExpression: __Expression + var mismatchedErrorDescription: String? + var differenceDescription: String? + var mismatchedExitConditionDescription: String? + var isPassing: Bool + var isRequired: Bool + var sourceLocation: SourceLocation + } + + private struct Issue: Sendable { + enum Kind: Sendable { + case unconditional + indirect case expectationFailed(_ expectation: Expectation) + indirect case confirmationMiscounted(actual: Int, expected: Int) + indirect case confirmationOutOfRange(actual: Int, expected: any ExpectedCount) + indirect case errorCaught(_ error: any Error) + indirect case timeLimitExceeded(timeLimitComponents: (seconds: Int64, attoseconds: Int64)) + case knownIssueNotRecorded + case apiMisused + case system + } + var kind: Kind + var comments: [Comment] + var sourceContext: SourceContext + } + + private struct SourceContext: Sendable { + var backtrace: Backtrace? + var sourceLocation: SourceLocation? + } + + private struct SourceLocation: Hashable, Sendable { + var fileID: String + var _filePath: String + var line: Int + var column: Int + var moduleName: String { + let firstSlash = fileID.firstIndex(of: "/")! + return String(fileID[.. Test?).self + ) + else { return nil } + return current() + } + + struct Case {} + private var name: String + private var displayName: String? + private var traits: [any Trait] + private var sourceLocation: SourceLocation + private var containingTypeInfo: TypeInfo? + private var xcTestCompatibleSelector: __XCTestCompatibleSelector? + fileprivate enum TestCasesState: @unchecked Sendable { + case unevaluated(_ function: @Sendable () async throws -> AnySequence) + case evaluated(_ testCases: AnySequence) + case failed(_ error: any Error) + } + fileprivate var testCasesState: TestCasesState? + private var parameters: [Parameter]? + private struct Parameter: Sendable { + var index: Int + var firstName: String + var secondName: String? + var typeInfo: TypeInfo + } + private var isSynthesized = false + + private var isSuite: Bool { + containingTypeInfo != nil && testCasesState == nil + } + fileprivate var id: ID { + var result = + containingTypeInfo.map(ID.init) + ?? ID(moduleName: sourceLocation.moduleName, nameComponents: [], sourceLocation: nil) + + if !isSuite { + result.nameComponents.append(name) + result.sourceLocation = sourceLocation + } + + return result + } + fileprivate struct ID: Hashable { + var moduleName: String + var nameComponents: [String] + var sourceLocation: SourceLocation? + init(moduleName: String, nameComponents: [String], sourceLocation: SourceLocation?) { + self.moduleName = moduleName + self.nameComponents = nameComponents + self.sourceLocation = sourceLocation + } + init(_ fullyQualifiedNameComponents: some Collection) { + moduleName = fullyQualifiedNameComponents.first ?? "" + if fullyQualifiedNameComponents.count > 0 { + nameComponents = Array(fullyQualifiedNameComponents.dropFirst()) + } else { + nameComponents = [] + } + } + init(typeInfo: TypeInfo) { + self.init(typeInfo.fullyQualifiedNameComponents) + } + } + } + + private protocol Trait: Sendable {} + + private struct TypeInfo: Sendable { + enum _Kind: Sendable { + case type(_ type: Any.Type) + case nameOnly(fullyQualifiedComponents: [String], unqualified: String, mangled: String?) + } + var _kind: _Kind + + static let _fullyQualifiedNameComponentsCache: + LockIsolated< + [ObjectIdentifier: [String]] + > = LockIsolated([:]) + var fullyQualifiedNameComponents: [String] { + switch _kind { + case let .type(type): + if let cachedResult = Self + ._fullyQualifiedNameComponentsCache.withLock({ $0[ObjectIdentifier(type)] }) + { + return cachedResult + } + var result = String(reflecting: type) + .split(separator: ".") + .map(String.init) + if let firstComponent = result.first, firstComponent.starts(with: "(extension in ") { + result[0] = String(firstComponent.split(separator: ":", maxSplits: 1).last!) + } + result = result.filter { !$0.starts(with: "(unknown context at") } + Self._fullyQualifiedNameComponentsCache.withLock { [result] in + $0[ObjectIdentifier(type)] = result + } + return result + + case let .nameOnly(fullyQualifiedComponents, _, _): + return fullyQualifiedComponents + } + } + } +#endif + +@usableFromInline +func function(for symbol: String) -> Any? { + let function = unsafeBitCast( + symbol: symbol, + in: "IssueReportingTestSupport", + to: (@convention(thin) () -> Any).self + ) + return function?() +} + +@usableFromInline +func unsafeBitCast(symbol: String, in library: String, to function: F.Type) -> F? { + #if os(Linux) + guard + let handle = dlopen("lib\(library).so", RTLD_LAZY), + let pointer = dlsym(handle, symbol) + else { return nil } + return unsafeBitCast(pointer, to: F.self) + #elseif canImport(Darwin) + guard + let handle = dlopen(nil, RTLD_LAZY), + let pointer = dlsym(handle, symbol) + else { return nil } + return unsafeBitCast(pointer, to: F.self) + #elseif os(Windows) + guard + let handle = LoadLibraryA("\(library).dll"), + let pointer = GetProcAddress(handle, symbol) + else { return nil } + return unsafeBitCast(pointer, to: F.self) + #else + return nil + #endif +} diff --git a/Sources/IssueReporting/Internal/UncheckedSendable.swift b/Sources/IssueReporting/Internal/UncheckedSendable.swift new file mode 100644 index 00000000..c602ccdd --- /dev/null +++ b/Sources/IssueReporting/Internal/UncheckedSendable.swift @@ -0,0 +1,9 @@ +@propertyWrapper +@usableFromInline +struct UncheckedSendable: @unchecked Sendable { + @usableFromInline + var wrappedValue: Value + init(wrappedValue value: Value) { + self.wrappedValue = value + } +} diff --git a/Sources/IssueReporting/Internal/Warn.swift b/Sources/IssueReporting/Internal/Warn.swift new file mode 100644 index 00000000..489ee3f6 --- /dev/null +++ b/Sources/IssueReporting/Internal/Warn.swift @@ -0,0 +1,14 @@ +#if os(Linux) + @preconcurrency import Foundation +#else + import Foundation +#endif + +#if canImport(WinSDK) + import WinSDK +#endif + +@usableFromInline +func printError(_ message: String) { + fputs("\(message)\n", stderr) +} diff --git a/Sources/IssueReporting/Internal/XCTest.swift b/Sources/IssueReporting/Internal/XCTest.swift new file mode 100644 index 00000000..b9d9816c --- /dev/null +++ b/Sources/IssueReporting/Internal/XCTest.swift @@ -0,0 +1,126 @@ +#if _runtime(_ObjC) + import Foundation +#endif + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(WinSDK) + import WinSDK +#endif + +@usableFromInline +func _XCTFail( + _ message: String = "", + file: StaticString = #filePath, + line: UInt = #line +) { + #if !_runtime(_ObjC) + guard !_XCTExpectedFailure.isInFailingBlock else { return } + #endif + guard let function = function(for: "$s25IssueReportingTestSupport8_XCTFailypyF") + else { + #if DEBUG + if let XCTFail = unsafeBitCast( + symbol: "$s6XCTest7XCTFail_4file4lineySS_s12StaticStringVSutF", + in: "XCTest", + to: (@convention(thin) (String, StaticString, UInt) -> Void).self + ) { + XCTFail(message, file, line) + return + } + #endif + printError( + """ + \(file):\(line): A failure was recorded without linking the XCTest framework. + + To fix this, add "IssueReportingTestSupport" as a dependency to your test target. + """ + ) + return + } + let XCTFail = function as! @Sendable (String, StaticString, UInt) -> Void + XCTFail(message, file, line) +} + +@_transparent +@usableFromInline +func _XCTExpectFailure( + _ failureReason: String? = nil, + enabled: Bool? = nil, + strict: Bool? = nil, + file: StaticString, + line: UInt, + failingBlock: () throws -> R +) rethrows -> R { + #if _runtime(_ObjC) + guard let function = function(for: "$s25IssueReportingTestSupport17_XCTExpectFailureypyF") + else { + #if DEBUG + guard enabled != false + else { return try failingBlock() } + if let pointer = dlsym(dlopen(nil, RTLD_NOW), "XCTExpectFailureWithOptionsInBlock"), + let XCTExpectedFailureOptions = NSClassFromString("XCTExpectedFailureOptions") + as Any as? NSObjectProtocol, + let options = strict ?? true + ? XCTExpectedFailureOptions + .perform(NSSelectorFromString("alloc"))?.takeUnretainedValue() + .perform(NSSelectorFromString("init"))?.takeUnretainedValue() + : XCTExpectedFailureOptions + .perform(NSSelectorFromString("nonStrictOptions"))?.takeUnretainedValue() + { + let XCTExpectFailureInBlock = unsafeBitCast( + pointer, + to: (@convention(c) (String?, AnyObject, () -> Void) -> Void).self + ) + var result: Result? + XCTExpectFailureInBlock(failureReason, options) { + result = Result { try failingBlock() } + } + return try result!._rethrowGet() + } + #else + printError( + """ + \(file):\(line): An expected failure was recorded without linking the XCTest framework. + + To fix this, add "IssueReportingTestSupport" as a dependency to your test target. + """ + ) + #endif + return try failingBlock() + } + let XCTExpectFailure = + function + as! @Sendable (String?, Bool?, Bool?, () throws -> Void) throws -> Void + var result: Result! + do { + try XCTExpectFailure(failureReason, enabled, strict) { + result = Result { try failingBlock() } + } + } catch { + fatalError() + } + return try result._rethrowGet() + #else + _XCTFail( + """ + Expecting failures is unavailable in XCTest on this platform. + + Omit this test from your suite by wrapping it in '#if canImport(Darwin)', or consider using \ + Swift Testing and 'withKnownIssue', instead. + """ + ) + return try _XCTExpectedFailure.$isInFailingBlock.withValue(true) { + try failingBlock() + } + #endif +} + +#if !_runtime(_ObjC) + @usableFromInline + enum _XCTExpectedFailure { + @TaskLocal public static var isInFailingBlock = false + } +#endif diff --git a/Sources/IssueReporting/IsTesting.swift b/Sources/IssueReporting/IsTesting.swift new file mode 100644 index 00000000..e709534d --- /dev/null +++ b/Sources/IssueReporting/IsTesting.swift @@ -0,0 +1,43 @@ +#if os(WASI) + public let isTesting = false +#else + import Foundation + + /// Whether or not the current process is running tests. + /// + /// You can use this information to prevent application code from running when hosting tests. For + /// example, you can wrap your app entry point: + /// + /// ```swift + /// import IssueReporting + /// + /// @main + /// struct MyApp: App { + /// var body: some Scene { + /// WindowGroup { + /// if !isTesting { + /// MyRootView() + /// } + /// } + /// } + /// } + /// + /// To detect if the current task is running inside a test, use ``TestContext/current``, instead. + public let isTesting = ProcessInfo.processInfo.isTesting + + extension ProcessInfo { + fileprivate var isTesting: Bool { + if environment.keys.contains("XCTestBundlePath") { return true } + if environment.keys.contains("XCTestConfigurationFilePath") { return true } + if environment.keys.contains("XCTestSessionIdentifier") { return true } + + return arguments.contains { argument in + let path = URL(fileURLWithPath: argument) + return path.lastPathComponent == "swiftpm-testing-helper" + || argument == "--testing-library" + || path.lastPathComponent == "xctest" + || path.pathExtension == "xctest" + } + } + } +#endif diff --git a/Sources/IssueReporting/IssueReporter.swift b/Sources/IssueReporting/IssueReporter.swift new file mode 100644 index 00000000..711fbd12 --- /dev/null +++ b/Sources/IssueReporting/IssueReporter.swift @@ -0,0 +1,197 @@ +/// A type that can report issues. +public protocol IssueReporter: Sendable { + /// Called when an issue is reported. + /// + /// - Parameters: + /// - message: A message describing the issue. + /// - fileID: The source `#fileID` associated with the issue. + /// - filePath: The source `#filePath` associated with the issue. + /// - line: The source `#line` associated with the issue. + /// - column: The source `#column` associated with the issue. + func reportIssue( + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) + + /// Called when an error is caught. + /// + /// The default implementation of this conformance simply calls + /// ``reportIssue(_:fileID:filePath:line:column:)`` with a description of the error. + /// + /// - Parameters: + /// - error: An error. + /// - message: A message describing the issue. + /// - fileID: The source `#fileID` associated with the issue. + /// - filePath: The source `#filePath` associated with the issue. + /// - line: The source `#line` associated with the issue. + /// - column: The source `#column` associated with the issue. + func reportIssue( + _ error: any Error, + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) + + /// Called when an expected issue is reported. + /// + /// The default implementation of this conformance simply ignores the issue. + /// + /// - Parameters: + /// - message: A message describing the issue. + /// - fileID: The source `#fileID` associated with the issue. + /// - filePath: The source `#filePath` associated with the issue. + /// - line: The source `#line` associated with the issue. + /// - column: The source `#column` associated with the issue. + func expectIssue( + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) + + /// Called when an expected error is reported. + /// + /// The default implementation of this conformance simply ignores the error. + /// + /// - Parameters: + /// - error: An error. + /// - message: A message describing the issue. + /// - fileID: The source `#fileID` associated with the issue. + /// - filePath: The source `#filePath` associated with the issue. + /// - line: The source `#line` associated with the issue. + /// - column: The source `#column` associated with the issue. + func expectIssue( + _ error: any Error, + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) +} + +extension IssueReporter { + public func reportIssue( + _ error: any Error, + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + reportIssue( + "Caught error: \(error)\(message().map { ": \($0)" } ?? "")", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } + + public func expectIssue( + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) {} + + public func expectIssue( + _ error: any Error, + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + expectIssue( + "Caught error: \(error)\(message().map { ": \($0)" } ?? "")", + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } +} + +public enum IssueReporters { + /// The task's current issue reporters. + /// + /// Assigning this directly will override the which issue reporters are notified in the current + /// task. This is generally useful at the entry point of your application, should you want to + /// replace the default reporting: + /// + /// ```swift + /// import IssueReporting + /// + /// @main + /// struct MyApp: App { + /// init() { + /// IssueReporters.current = [.fatalError] + /// } + /// + /// var body: some Scene { + /// // ... + /// } + /// } + /// ``` + /// + /// Issue reporters are fed issues in order. + /// + /// To override the task's issue reporters for a scoped operation, prefer + /// ``withIssueReporters(_:operation:)-91179``. + public static var current: [any IssueReporter] { + get { _current.withLock { $0 } } + set { _current.withLock { $0 = newValue } } + } + + @TaskLocal fileprivate static var _current = LockIsolated<[any IssueReporter]>([.runtimeWarning]) +} + +/// Overrides the task's issue reporters for the duration of the synchronous operation. +/// +/// For example, you can ignore all reported issues by passing an empty array of reporters: +/// +/// ```swift +/// withIssueReporters([]) { +/// // Reported issues will be ignored here... +/// } +/// ``` +/// +/// Or, to temporarily add a custom reporter, you can append it to ``IssueReporters/current``: +/// +/// ```swift +/// withIssueReporters(IssueReporters.current + [MyCustomReporter()]) { +/// // Reported issues will be fed to the +/// } +/// ``` +/// +/// - Parameters: +/// - reporters: Issue reporters to notify during the operation. +/// - operation: A synchronous operation. +public func withIssueReporters( + _ reporters: [any IssueReporter], + operation: () throws -> R +) rethrows -> R { + try IssueReporters.$_current.withValue(LockIsolated(reporters), operation: operation) +} + +/// Overrides the task's issue reporters for the duration of the asynchronous operation. +/// +/// An asynchronous version of ``withIssueReporters(_:operation:)-91179``. +/// +/// - Parameters: +/// - reporters: Issue reporters to notify during the operation. +/// - operation: An asynchronous operation. +public func withIssueReporters( + _ reporters: [any IssueReporter], + operation: () async throws -> R +) async rethrows -> R { + try await IssueReporters.$_current.withValue(LockIsolated(reporters), operation: operation) +} diff --git a/Sources/IssueReporting/IssueReporters/BreakpointReporter.swift b/Sources/IssueReporting/IssueReporters/BreakpointReporter.swift new file mode 100644 index 00000000..eb72e633 --- /dev/null +++ b/Sources/IssueReporting/IssueReporters/BreakpointReporter.swift @@ -0,0 +1,52 @@ +#if canImport(Darwin) + import Darwin + + extension IssueReporter where Self == _BreakpointReporter { + /// An issue reporter that pauses program execution when a debugger is attached. + /// + /// Logs a warning to the console and raises `SIGTRAP` when an issue is received. + public static var breakpoint: Self { Self() } + } + + /// A type representing an issue reporter that pauses program execution when a debugger is + /// attached. + /// + /// Use ``IssueReporter/breakpoint`` to create one of these values. + public struct _BreakpointReporter: IssueReporter { + public func reportIssue( + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + var message = message() ?? "" + if message.isEmpty { + message = "Issue reported" + } + printError("\(fileID):\(line): \(message)") + guard isDebuggerAttached else { return } + printError( + """ + + Caught debug breakpoint. Type "continue" ("c") to resume execution. + """ + ) + raise(SIGTRAP) + } + + var isDebuggerAttached: Bool { + var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] + var info: kinfo_proc = kinfo_proc() + var info_size = MemoryLayout.size + + return name.withUnsafeMutableBytes { + $0.bindMemory(to: Int32.self).baseAddress + .map { + sysctl($0, 4, &info, &info_size, nil, 0) != -1 && info.kp_proc.p_flag & P_TRACED != 0 + } + ?? false + } + } + } +#endif diff --git a/Sources/IssueReporting/IssueReporters/FatalErrorReporter.swift b/Sources/IssueReporting/IssueReporters/FatalErrorReporter.swift new file mode 100644 index 00000000..d51db167 --- /dev/null +++ b/Sources/IssueReporting/IssueReporters/FatalErrorReporter.swift @@ -0,0 +1,25 @@ +extension IssueReporter where Self == _FatalErrorReporter { + /// An issue reporter that terminates program execution. + /// + /// Calls Swift's `fatalError` function when an issue is received. + public static var fatalError: Self { Self() } +} + +/// A type representing an issue reporter that terminates program execution. +/// +/// Use ``IssueReporter/fatalError`` to create one of these values. +public struct _FatalErrorReporter: IssueReporter { + public func reportIssue( + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + var message = message() ?? "" + if message.isEmpty { + message = "Issue reported" + } + Swift.fatalError(message, file: filePath, line: line) + } +} diff --git a/Sources/IssueReporting/IssueReporters/RuntimeWarningReporter.swift b/Sources/IssueReporting/IssueReporters/RuntimeWarningReporter.swift new file mode 100644 index 00000000..2ef970e5 --- /dev/null +++ b/Sources/IssueReporting/IssueReporters/RuntimeWarningReporter.swift @@ -0,0 +1,118 @@ +import Foundation + +#if canImport(os) + import os +#endif + +extension IssueReporter where Self == _RuntimeWarningReporter { + /// An issue reporter that emits "purple" runtime warnings to Xcode and logs fault-level messages + /// to the console. + /// + /// This is the default issue reporter. On non-Apple platforms it logs messages to `stderr`. + /// + /// If this issue reporter receives an expected issue, it will log an info-level message to the + /// console, instead. + #if canImport(Darwin) + @_transparent + #endif + public static var runtimeWarning: Self { Self() } +} + +/// A type representing an issue reporter that emits "purple" runtime warnings to Xcode and logs +/// fault-level messages to the console. +/// +/// Use ``IssueReporter/runtimeWarning`` to create one of these values. +public struct _RuntimeWarningReporter: IssueReporter { + #if canImport(os) + @UncheckedSendable + #if canImport(Darwin) + @_transparent + #endif + @usableFromInline var dso: UnsafeRawPointer + + init(dso: UnsafeRawPointer) { + self.dso = dso + } + + @usableFromInline + init() { + // NB: Xcode runtime warnings offer a much better experience than traditional assertions and + // breakpoints, but Apple provides no means of creating custom runtime warnings ourselves. + // To work around this, we hook into SwiftUI's runtime issue delivery mechanism, instead. + // + // Feedback filed: https://gist.github.com/stephencelis/a8d06383ed6ccde3e5ef5d1b3ad52bbc + let count = _dyld_image_count() + for i in 0.. String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + #if canImport(os) + guard ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" + else { + print("🟣 \(fileID):\(line): \(message() ?? "")") + return + } + let moduleName = String( + Substring("\(fileID)".utf8.prefix(while: { $0 != UTF8.CodeUnit(ascii: "/") })) + ) + var message = message() ?? "" + if message.isEmpty { + message = "Issue reported" + } + os_log( + .fault, + dso: dso, + log: OSLog(subsystem: "com.apple.runtime-issues", category: moduleName), + "%@", + "\(isTesting ? "\(fileID):\(line): " : "")\(message)" + ) + #else + printError("\(fileID):\(line): \(message() ?? "")") + #endif + } + + public func expectIssue( + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + #if canImport(os) + let moduleName = String( + Substring("\(fileID)".utf8.prefix(while: { $0 != UTF8.CodeUnit(ascii: "/") })) + ) + var message = message() ?? "" + if message.isEmpty { + message = "Issue expected" + } + os_log( + .info, + log: OSLog(subsystem: "co.pointfree.expected-issues", category: moduleName), + "%@", + "\(isTesting ? "\(fileID):\(line): " : "")\(message)" + ) + #else + print("\(fileID):\(line): \(message() ?? "")") + #endif + } +} diff --git a/Sources/IssueReporting/ReportIssue.swift b/Sources/IssueReporting/ReportIssue.swift new file mode 100644 index 00000000..3a2af155 --- /dev/null +++ b/Sources/IssueReporting/ReportIssue.swift @@ -0,0 +1,151 @@ +/// Report an issue. +/// +/// Invoking this function has two different behaviors depending on the context: +/// +/// * When running your code in a non-testing context, this method will loop over the +/// collection of issue reports registered and invoke them. The default issue reporter for the +/// library is ``IssueReporter/runtimeWarning``, which emits a purple, runtime warning in Xcode: +/// +/// ![A purple runtime warning in Xcode showing that an issue has been reported.](runtime-warning) +/// +/// But you can there are also [other issue reports]() you +/// can use, and you can create your own. +/// +/// * When running your app in tests (both XCTest and Swift's native Testing framework), it will +/// emit a test failure. This allows you to get test coverage on your reported issues, both expected +/// and unexpected ones. +/// +/// ![A test failure in Xcode where an issue has been reported.](test-failure) +/// +/// [Issue.record]: https://developer.apple.com/documentation/testing/issue/record(_:sourcelocation:) +/// [XCTFail]: https://developer.apple.com/documentation/xctest/1500970-xctfail/ +/// +/// - Parameters: +/// - message: A message describing the issue. +/// - fileID: The source `#fileID` associated with the issue. +/// - filePath: The source `#filePath` associated with the issue. +/// - line: The source `#line` associated with the issue. +/// - column: The source `#column` associated with the issue. +@_transparent +public func reportIssue( + _ message: @autoclosure () -> String? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column +) { + guard let context = TestContext.current else { + guard !isTesting else { return } + if let observer = FailureObserver.current { + observer.withLock { $0 += 1 } + for reporter in IssueReporters.current { + reporter.expectIssue( + message(), + fileID: IssueContext.current?.fileID ?? fileID, + filePath: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line, + column: IssueContext.current?.column ?? column + ) + } + } else { + for reporter in IssueReporters.current { + reporter.reportIssue( + message(), + fileID: IssueContext.current?.fileID ?? fileID, + filePath: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line, + column: IssueContext.current?.column ?? column + ) + } + } + return + } + + switch context { + case .swiftTesting: + _recordIssue( + message: message(), + fileID: "\(IssueContext.current?.fileID ?? fileID)", + filePath: "\(IssueContext.current?.filePath ?? filePath)", + line: Int(IssueContext.current?.line ?? line), + column: Int(IssueContext.current?.column ?? column) + ) + case .xcTest: + _XCTFail( + message().withAppHostWarningIfNeeded() ?? "", + file: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line + ) + @unknown default: break + } +} + +/// Report a caught error. +/// +/// This function behaves similarly to ``reportIssue(_:fileID:filePath:line:column:)``, but for +/// reporting errors. +/// +/// - Parameters: +/// - error: The error that caused the issue. +/// - message: A message describing the expectation. +/// - fileID: The source `#fileID` associated with the issue. +/// - filePath: The source `#filePath` associated with the issue. +/// - line: The source `#line` associated with the issue. +/// - column: The source `#column` associated with the issue. +@_transparent +public func reportIssue( + _ error: any Error, + _ message: @autoclosure () -> String? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column +) { + guard let context = TestContext.current else { + guard !isTesting else { return } + if let observer = FailureObserver.current { + observer.withLock { $0 += 1 } + for reporter in IssueReporters.current { + reporter.expectIssue( + error, + message(), + fileID: IssueContext.current?.fileID ?? fileID, + filePath: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line, + column: IssueContext.current?.column ?? column + ) + } + } else { + for reporter in IssueReporters.current { + reporter.reportIssue( + error, + message(), + fileID: IssueContext.current?.fileID ?? fileID, + filePath: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line, + column: IssueContext.current?.column ?? column + ) + } + } + return + } + + switch context { + case .swiftTesting: + _recordError( + error: error, + message: message(), + fileID: "\(IssueContext.current?.fileID ?? fileID)", + filePath: "\(IssueContext.current?.filePath ?? filePath)", + line: Int(IssueContext.current?.line ?? line), + column: Int(IssueContext.current?.column ?? column) + ) + case .xcTest: + _XCTFail( + "Caught error: \(error)\(message().map { ": \($0)" } ?? "")".withAppHostWarningIfNeeded(), + file: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line + ) + @unknown default: break + } +} diff --git a/Sources/IssueReporting/TestContext.swift b/Sources/IssueReporting/TestContext.swift new file mode 100644 index 00000000..9d8c6128 --- /dev/null +++ b/Sources/IssueReporting/TestContext.swift @@ -0,0 +1,79 @@ +/// A type representing the context in which a test is being run, _i.e._ either in Swift's native +/// Testing framework, or Xcode's XCTest framework. +public enum TestContext: Equatable, Sendable { + /// The Swift Testing framework. + case swiftTesting(Testing?) + + /// The XCTest framework. + case xcTest + + /// The context associated with current test. + /// + /// How the test context is detected depends on the framework: + /// + /// * If Swift Testing is running, _and_ this is called from the current test's task, this will + /// return ``swiftTesting`` with an associated value of the current test. You can invoke + /// ``isSwiftTesting`` to detect if the test is currently in the Swift Testing framework, + /// which is equivalent to checking `Test.current != nil`, but safe to do from library and + /// application code. + /// + /// * If XCTest is running, _and_ this is called during the execution of a test _regardless_ of + /// task, this will return ``xcTest``. + /// + /// If executed outside of a test process, this will return `nil`. + public static var current: Self? { + guard isTesting else { return nil } + if let currentTestID = _currentTestID() { + return .swiftTesting(Testing(id: currentTestID)) + } else { + return .xcTest + } + } + + /// Determines if the test context is Swift's native Testing framework. + public var isSwiftTesting: Bool { + guard case .swiftTesting = self + else { return false } + return true + } + + public struct Testing: Equatable, Sendable { + public let test: Test + + public struct Test: Equatable, Hashable, Identifiable, Sendable { + public let id: ID + + public struct ID: Equatable, Hashable, @unchecked Sendable { + public let rawValue: AnyHashable + } + } + } + + @available(*, deprecated, message: "Test using pattern matching, instead.") + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.swiftTesting(nil), .swiftTesting), + (.swiftTesting, .swiftTesting(nil)), + (.xcTest, .xcTest): + return true + case (.swiftTesting(let lhs), .swiftTesting(let rhs)): + return lhs == rhs + case (.swiftTesting, .xcTest), (.xcTest, .swiftTesting): + return false + } + } + + @available( + *, deprecated, + message: "Test for '.swiftTesting' using pattern matching or 'isSwiftTesting', instead." + ) + public static var swiftTesting: Self { + .swiftTesting(nil) + } +} + +extension TestContext.Testing { + fileprivate init(id: AnyHashable) { + self.init(test: Test(id: Test.ID(rawValue: id))) + } +} diff --git a/Sources/IssueReporting/Unimplemented.swift b/Sources/IssueReporting/Unimplemented.swift new file mode 100644 index 00000000..172b4557 --- /dev/null +++ b/Sources/IssueReporting/Unimplemented.swift @@ -0,0 +1,303 @@ +/// Returns a closure that reports an issue when invoked. +/// +/// Useful for creating closures that need to be overridden by users of your API, and if it is +/// ever invoked without being overridden an issue will be reported. See +/// for more information. +/// +/// - Parameters: +/// - description: An optional description of the unimplemented closure. +/// - placeholder: A placeholder value returned from the closure when left unimplemented. +/// - fileID: The fileID. +/// - filePath: The filePath. +/// - function: The function. +/// - line: The line. +/// - column: The column. +/// - Returns: A closure that reports an issue when invoked. +public func unimplemented( + _ description: @autoclosure @escaping @Sendable () -> String = "", + placeholder: @autoclosure @escaping @Sendable () -> Result = (), + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column +) -> @Sendable (repeat each Argument) -> Result { + return { (argument: repeat each Argument) in + _fail( + description(), + (repeat each argument), + fileID: fileID, + filePath: filePath, + function: function, + line: line, + column: column + ) + return placeholder() + } +} + +/// Returns a throwing closure that reports an issue and throws an error when invoked. +/// +/// Useful for creating closures that need to be overridden by users of your API, and if it is +/// ever invoked without being overridden an issue will be reported. See +/// for more information. +/// +/// - Parameters: +/// - description: An optional description of the unimplemented closure. +/// - fileID: The fileID. +/// - filePath: The filePath. +/// - function: The function. +/// - line: The line. +/// - column: The column. +/// - Returns: A throwing closure that reports an issue and throws an error when invoked. +public func unimplemented( + _ description: @autoclosure @escaping @Sendable () -> String = "", + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column +) -> @Sendable (repeat each Argument) throws -> Result { + return { (argument: repeat each Argument) in + let description = description() + _fail( + description, + (repeat each argument), + fileID: fileID, + filePath: filePath, + function: function, + line: line, + column: column + ) + throw UnimplementedFailure(description: description) + } +} + +#if compiler(>=6) + /// Returns a throwing closure that reports an issue and throws a given error when invoked. + /// + /// Useful for creating closures that need to be overridden by users of your API, and if it is + /// ever invoked without being overridden an issue will be reported. See + /// for more information. + /// + /// - Parameters: + /// - description: An optional description of the unimplemented closure. + /// - failure: The error thrown by the unimplemented closure. + /// - fileID: The fileID. + /// - filePath: The filePath. + /// - function: The function. + /// - line: The line. + /// - column: The column. + /// - Returns: A throwing closure that reports an issue and throws an error when invoked. + public func unimplemented( + _ description: @autoclosure @escaping @Sendable () -> String = "", + throwing failure: @autoclosure @escaping @Sendable () -> Failure, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column + ) -> @Sendable (repeat each Argument) throws(Failure) -> Result { + return { (argument: repeat each Argument) throws(Failure) in + let description = description() + _fail( + description, + (repeat each argument), + fileID: fileID, + filePath: filePath, + function: function, + line: line, + column: column + ) + throw failure() + } + } +#endif + +/// Returns an asynchronous closure that reports an issue when invoked. +/// +/// Useful for creating closures that need to be overridden by users of your API, and if it is +/// ever invoked without being overridden an issue will be reported. See +/// for more information. +/// +/// - Parameters: +/// - description: An optional description of the unimplemented closure. +/// - placeholder: A placeholder value returned from the closure when left unimplemented. +/// - fileID: The fileID. +/// - filePath: The filePath. +/// - function: The function. +/// - line: The line. +/// - column: The column. +/// - Returns: An asynchronous closure that reports an issue when invoked. +public func unimplemented( + _ description: @autoclosure @escaping @Sendable () -> String = "", + placeholder: @autoclosure @escaping @Sendable () -> Result = (), + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column +) -> @Sendable (repeat each Argument) async -> Result { + return { (argument: repeat each Argument) in + _fail( + description(), + (repeat each argument), + fileID: fileID, + filePath: filePath, + function: function, + line: line, + column: column + ) + return placeholder() + } +} + +/// Returns a throwing, asynchronous closure that reports an issue and throws an error when invoked. +/// +/// Useful for creating closures that need to be overridden by users of your API, and if it is +/// ever invoked without being overridden an issue will be reported. See +/// for more information. +/// +/// - Parameters: +/// - description: An optional description of the unimplemented closure. +/// - fileID: The fileID. +/// - filePath: The filePath. +/// - function: The function. +/// - line: The line. +/// - column: The column. +/// - Returns: A throwing, asynchronous closure that reports an issue and throws an error when +/// invoked. +public func unimplemented( + _ description: @autoclosure @escaping @Sendable () -> String = "", + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column +) -> @Sendable (repeat each Argument) async throws -> Result { + return { (argument: repeat each Argument) in + let description = description() + _fail( + description, + (repeat each argument), + fileID: fileID, + filePath: filePath, + function: function, + line: line, + column: column + ) + throw UnimplementedFailure(description: description) + } +} + +#if compiler(>=6) + /// Returns a throwing, asynchronous closure that reports an issue and throws a given error when + /// invoked. + /// + /// Useful for creating closures that need to be overridden by users of your API, and if it is + /// ever invoked without being overridden an issue will be reported. See + /// for more information. + /// + /// - Parameters: + /// - description: An optional description of the unimplemented closure. + /// - failure: The error thrown by the unimplemented closure. + /// - fileID: The fileID. + /// - filePath: The filePath. + /// - function: The function. + /// - line: The line. + /// - column: The column. + /// - Returns: A throwing, asynchronous closure that reports an issue and throws an error when + /// invoked. + public func unimplemented( + _ description: @autoclosure @escaping @Sendable () -> String = "", + throwing failure: @autoclosure @escaping @Sendable () -> Failure, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column + ) -> @Sendable (repeat each Argument) async throws(Failure) -> Result { + return { (argument: repeat each Argument) async throws(Failure) in + let description = description() + _fail( + description, + (repeat each argument), + fileID: fileID, + filePath: filePath, + function: function, + line: line, + column: column + ) + throw failure() + } + } +#endif + +@_disfavoredOverload +public func unimplemented( + _ description: @autoclosure @escaping @Sendable () -> String = "", + placeholder: @autoclosure @escaping @Sendable () -> Result = (), + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column +) -> Result { + _fail( + description(), + nil, + fileID: fileID, + filePath: filePath, + function: function, + line: line, + column: column + ) + return placeholder() +} + +/// An error thrown from throwing `unimplemented` closures. +public struct UnimplementedFailure: Error { + public let description: String + + public init(description: String) { + self.description = description + } +} + +package func _fail( + _ description: String, + _ parameters: Any?, + fileID: StaticString, + filePath: StaticString, + function: StaticString, + line: UInt, + column: UInt +) { + var debugDescription = """ + … + + Defined in '\(function)' at: + \(fileID):\(line) + """ + if let parameters { + var parametersDescription = "" + debugPrint(parameters, terminator: "", to: ¶metersDescription) + debugDescription.append( + """ + + + Invoked with: + \(parametersDescription) + """ + ) + } + reportIssue( + """ + Unimplemented\(description.isEmpty ? "" : ": \(description)")\(debugDescription) + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) +} diff --git a/Sources/IssueReporting/WithExpectedIssue.swift b/Sources/IssueReporting/WithExpectedIssue.swift new file mode 100644 index 00000000..718948ad --- /dev/null +++ b/Sources/IssueReporting/WithExpectedIssue.swift @@ -0,0 +1,203 @@ +/// Invoke a function that has an issue that is expected to occur during its execution. +/// +/// A generalized version of Swift Testing's [`withKnownIssue`][withKnownIssue] that works with this +/// library's [`reportIssue`]() instead of just +/// Swift Testing's tools. +/// +/// At runtime it can be used to lower the log level of reported issues: +/// +/// ```swift +/// // Emits a "purple" warning to Xcode and logs a fault-level message to console +/// reportIssue("Failed") +/// +/// withExpectedIssue { +/// // Simply logs an info-level message +/// reportIssue("Failed") +/// } +/// ``` +/// +/// During test runs, the issue will be sent to Swift Testing's [`withKnownIssue`][withKnownIssue] +/// _or_ XCTest's [`XCTExpectFailure`][XCTExpectFailure] accordingly, which means you can use it to +/// drive custom assertion helpers that you want to work in both Swift Testing and XCTest. +/// +/// Errors thrown from the function are automatically caught and reported as issues: +/// +/// ```swift +/// withExpectedIssue { +/// // If this function throws an error, it will be caught and reported as an issue +/// try functionThatCanFail() +/// } +/// ``` +/// +/// [withKnownIssue]: https://developer.apple.com/documentation/testing/withknownissue(_:isintermittent:fileid:filepath:line:column:_:)-30kgk +/// [XCTExpectFailure]: https://developer.apple.com/documentation/xctest/3727246-xctexpectfailure/ +/// +/// - Parameters: +/// - message: An optional message describing the expected issue. +/// - isIntermittent: Whether or not the expected issue occurs intermittently. If this argument is +/// `true` and the expected issue does not occur, no secondary issue is recorded. +/// - fileID: The source `#fileID` associated with the issue. +/// - filePath: The source `#filePath` associated with the issue. +/// - line: The source `#line` associated with the issue. +/// - column: The source `#column` associated with the issue. +/// - body: The function to invoke. +@_transparent +public func withExpectedIssue( + _ message: String? = nil, + isIntermittent: Bool = false, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column, + _ body: () throws -> Void +) { + guard let context = TestContext.current else { + guard !isTesting else { return } + let observer = FailureObserver() + FailureObserver.$current.withValue(observer) { + do { + try body() + if observer.withLock({ $0 == 0 }), !isIntermittent { + for reporter in IssueReporters.current { + reporter.reportIssue( + "Known issue was not recorded\(message.map { ": \($0)" } ?? "")", + fileID: IssueContext.current?.fileID ?? fileID, + filePath: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line, + column: IssueContext.current?.column ?? column + ) + } + } + } catch { + for reporter in IssueReporters.current { + reporter.expectIssue( + error, + message, + fileID: IssueContext.current?.fileID ?? fileID, + filePath: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line, + column: IssueContext.current?.column ?? column + ) + } + } + } + return + } + + switch context { + case .swiftTesting: + _withKnownIssue( + message, + isIntermittent: isIntermittent, + fileID: fileID.description, + filePath: filePath.description, + line: Int(line), + column: Int(column), + body + ) + case .xcTest: + _XCTExpectFailure( + message.withAppHostWarningIfNeeded(), + strict: !isIntermittent, + file: filePath, + line: line + ) { + do { + try body() + } catch { + reportIssue(error, fileID: fileID, filePath: filePath, line: line, column: column) + } + } + @unknown default: break + } +} + +/// Invoke an asynchronous function that has an issue that is expected to occur during its +/// execution. +/// +/// An asynchronous version of +/// ``withExpectedIssue(_:isIntermittent:fileID:filePath:line:column:_:)-9pinm``. +/// +/// > Warning: The asynchronous version of this function is incompatible with XCTest and will +/// > unconditionally report an issue when used, instead. +/// +/// - Parameters: +/// - message: An optional message describing the expected issue. +/// - isIntermittent: Whether or not the known expected occurs intermittently. If this argument is +/// `true` and the expected issue does not occur, no secondary issue is recorded. +/// - fileID: The source `#fileID` associated with the issue. +/// - filePath: The source `#filePath` associated with the issue. +/// - line: The source `#line` associated with the issue. +/// - column: The source `#column` associated with the issue. +/// - body: The asynchronous function to invoke. +@_transparent +public func withExpectedIssue( + _ message: String? = nil, + isIntermittent: Bool = false, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column, + _ body: () async throws -> Void +) async { + + guard let context = TestContext.current else { + guard !isTesting else { return } + let observer = FailureObserver() + await FailureObserver.$current.withValue(observer) { + do { + try await body() + if observer.withLock({ $0 == 0 }), !isIntermittent { + for reporter in IssueReporters.current { + reporter.reportIssue( + "Known issue was not recorded\(message.map { ": \($0)" } ?? "")", + fileID: IssueContext.current?.fileID ?? fileID, + filePath: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line, + column: IssueContext.current?.column ?? column + ) + } + } + } catch { + for reporter in IssueReporters.current { + reporter.expectIssue( + error, + message, + fileID: IssueContext.current?.fileID ?? fileID, + filePath: IssueContext.current?.filePath ?? filePath, + line: IssueContext.current?.line ?? line, + column: IssueContext.current?.column ?? column + ) + } + } + } + return + } + + switch context { + case .swiftTesting: + await _withKnownIssue( + message, + isIntermittent: isIntermittent, + fileID: fileID.description, + filePath: filePath.description, + line: Int(line), + column: Int(column), + body + ) + case .xcTest: + reportIssue( + """ + Asynchronously expecting failures is unavailable in XCTest. + + Omit this test from your XCTest suite, or consider using Swift Testing, instead. + """, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + try? await body() + @unknown default: break + } +} diff --git a/Sources/IssueReporting/WithIssueContext.swift b/Sources/IssueReporting/WithIssueContext.swift new file mode 100644 index 00000000..1bca4086 --- /dev/null +++ b/Sources/IssueReporting/WithIssueContext.swift @@ -0,0 +1,62 @@ +/// Sets the context for issues reported for the duration of the synchronous operation. +/// +/// This context will override the implicit context from the call sites of +/// ``reportIssue(_:fileID:filePath:line:column:)`` and +/// ``withExpectedIssue(_:isIntermittent:fileID:filePath:line:column:_:)-9pinm``, and can be +/// leveraged by custom test helpers that want to associate reported issues with specific source +/// code. +/// +/// - Parameters: +/// - fileID: The source `#fileID` to associate with issues reported during the operation. +/// - filePath: The source `#filePath` to associate with issues reported during the operation. +/// - line: The source `#line` to associate with issues reported during the operation. +/// - column: The source `#column` to associate with issues reported during the operation. +/// - operation: A synchronous operation. +public func withIssueContext( + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt, + operation: () throws -> R +) rethrows -> R { + try IssueContext.$current.withValue( + IssueContext(fileID: fileID, filePath: filePath, line: line, column: column), + operation: operation + ) +} + +/// Sets the context for issues reported for the duration of the asynchronous operation. +/// +/// An asynchronous version of ``withIssueContext(fileID:filePath:line:column:operation:)-97lux``. +/// +/// - Parameters: +/// - fileID: The source `#fileID` to associate with issues reported during the operation. +/// - filePath: The source `#filePath` to associate with issues reported during the operation. +/// - line: The source `#line` to associate with issues reported during the operation. +/// - column: The source `#column` to associate with issues reported during the operation. +/// - operation: An asynchronous operation. +public func withIssueContext( + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt, + operation: () async throws -> R +) async rethrows -> R { + try await IssueContext.$current.withValue( + IssueContext(fileID: fileID, filePath: filePath, line: line, column: column), + operation: operation + ) +} + +@usableFromInline +struct IssueContext: Sendable { + @TaskLocal public static var current: Self? + @usableFromInline + let fileID: StaticString + @usableFromInline + let filePath: StaticString + @usableFromInline + let line: UInt + @usableFromInline + let column: UInt +} diff --git a/Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift b/Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift index 23dd2bdb..87602cdc 100644 --- a/Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift +++ b/Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift @@ -8,7 +8,7 @@ import AnyCodable import Foundation import KlaviyoCore -import Perception +import KPerception import UIKit typealias DeviceMetadata = PushTokenPayload.PushToken.Attributes.MetaData diff --git a/Sources/Perception/Environment.swift b/Sources/Perception/Environment.swift new file mode 100644 index 00000000..0179b06d --- /dev/null +++ b/Sources/Perception/Environment.swift @@ -0,0 +1,66 @@ +#if canImport(SwiftUI) + import SwiftUI + + extension Environment { + /// Creates an environment property to read a perceptible object from the environment. + /// + /// A backport of SwiftUI's `Environment.init` that takes an observable object. + /// + /// - Parameter objectType: The type of the `Perceptible` object to read from the environment. + @_disfavoredOverload + public init(_ objectType: Value.Type) where Value: AnyObject & Perceptible { + self.init(\.[unwrap: \Value.self]) + } + + /// Creates an environment property to read a perceptible object from the environment, returning + /// `nil` if no corresponding object has been set in the current view's environment. + /// + /// A backport of SwiftUI's `Environment.init` that takes an observable object. + /// + /// - Parameter objectType: The type of the `Perceptible` object to read from the environment. + @_disfavoredOverload + public init(_ objectType: T.Type) where Value == T? { + self.init(\.[\T.self]) + } + } + + extension View { + /// Places a perceptible object in the view’s environment. + /// + /// A backport of SwiftUI's `View.environment` that takes an observable object. + /// + /// - Parameter object: The object to set for this object's type in the environment, or `nil` to + /// clear an object of this type from the environment. + /// - Returns: A view that has the specified object in its environment. + @_disfavoredOverload + public func environment(_ object: T?) -> some View { + self.environment(\.[\T.self], object) + } + } + + private struct PerceptibleKey: EnvironmentKey { + static var defaultValue: T? { nil } + } + + extension EnvironmentValues { + fileprivate subscript(_: KeyPath) -> T? { + get { self[PerceptibleKey.self] } + set { self[PerceptibleKey.self] = newValue } + } + + fileprivate subscript(unwrap _: KeyPath) -> T { + get { + guard let object = self[\T.self] else { + fatalError( + """ + No perceptible object of type \(T.self) found. A View.environment(_:) for \(T.self) may \ + be missing as an ancestor of this view. + """ + ) + } + return object + } + set { self[\T.self] = newValue } + } + } +#endif diff --git a/Sources/Perception/Internal/AnySendable.swift b/Sources/Perception/Internal/AnySendable.swift new file mode 100644 index 00000000..9e19ada1 --- /dev/null +++ b/Sources/Perception/Internal/AnySendable.swift @@ -0,0 +1,7 @@ +struct AnySendable: @unchecked Sendable { + let base: Any + @inlinable + init(_ base: Base) { + self.base = base + } +} diff --git a/Sources/Perception/Internal/BetaChecking.swift b/Sources/Perception/Internal/BetaChecking.swift new file mode 100644 index 00000000..dcaa7093 --- /dev/null +++ b/Sources/Perception/Internal/BetaChecking.swift @@ -0,0 +1,27 @@ +import Foundation + +// NB: This boolean is used to work around a crash experienced by beta users of Observation when +// `Observable` was still a marker protocol and we attempt to dynamically cast to +// `any Observable`. +let isObservationBeta: Bool = { + #if os(iOS) || os(tvOS) || os(watchOS) + let os = ProcessInfo.processInfo.operatingSystemVersion + #if os(iOS) || os(tvOS) + if (os.majorVersion, os.minorVersion, os.patchVersion) != (17, 0, 0) { + return false + } + #elseif os(watchOS) + if (os.majorVersion, os.minorVersion, os.patchVersion) != (10, 0, 0) { + return false + } + #endif + var size = 0 + sysctlbyname("kern.osversion", nil, &size, nil, 0) + var version = [CChar](repeating: 0, count: size) + let ret = sysctlbyname("kern.osversion", &version, &size, nil, 0) + // NB: Beta builds end with a lowercase character (_e.g._, '21A5277j') + return ret == 0 ? String(cString: version).last?.isLowercase == true : false + #else + return false + #endif +}() diff --git a/Sources/Perception/Internal/Exports.swift b/Sources/Perception/Internal/Exports.swift new file mode 100644 index 00000000..3814f9c8 --- /dev/null +++ b/Sources/Perception/Internal/Exports.swift @@ -0,0 +1,8 @@ +#if canImport(Observation) + @_exported import Observation + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + public typealias _Observable = Observation.Observable +#else + @available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) + public protocol _Observable {} +#endif diff --git a/Sources/Perception/Internal/Locking.swift b/Sources/Perception/Internal/Locking.swift new file mode 100644 index 00000000..0bcdc368 --- /dev/null +++ b/Sources/Perception/Internal/Locking.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import Foundation + +internal struct _ManagedCriticalState { + private let lock = NSLock() + final private class LockedBuffer: ManagedBuffer {} + + private let buffer: ManagedBuffer + + internal init(_ buffer: ManagedBuffer) { + self.buffer = buffer + } + + internal init(_ initial: State) { + let roundedSize = + (MemoryLayout.size - 1) / MemoryLayout.size + self.init( + LockedBuffer.create(minimumCapacity: Swift.max(roundedSize, 1)) { buffer in + return initial + }) + } + + internal func withCriticalRegion( + _ critical: (inout State) throws -> R + ) rethrows -> R { + try buffer.withUnsafeMutablePointers { header, lock in + self.lock.lock() + defer { + self.lock.unlock() + } + return try critical(&header.pointee) + } + } +} + +extension _ManagedCriticalState: @unchecked Sendable where State: Sendable {} + +extension _ManagedCriticalState: Identifiable { + internal var id: ObjectIdentifier { + ObjectIdentifier(buffer) + } +} + +extension NSLock { + @inlinable @discardableResult + @_spi(Internals) public func sync(work: () -> R) -> R { + self.lock() + defer { self.unlock() } + return work() + } +} diff --git a/Sources/Perception/Internal/ThreadLocal.swift b/Sources/Perception/Internal/ThreadLocal.swift new file mode 100644 index 00000000..059184b5 --- /dev/null +++ b/Sources/Perception/Internal/ThreadLocal.swift @@ -0,0 +1,24 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2022 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import Foundation + +struct _ThreadLocal { + #if os(WASI) + static var value: UnsafeMutableRawPointer? + #else + static var value: UnsafeMutableRawPointer? { + get { Thread.current.threadDictionary[Key()] as! UnsafeMutableRawPointer? } + set { Thread.current.threadDictionary[Key()] = newValue } + } + private struct Key: Hashable {} + #endif +} diff --git a/Sources/Perception/Internal/UncheckedSendable.swift b/Sources/Perception/Internal/UncheckedSendable.swift new file mode 100644 index 00000000..712c62f3 --- /dev/null +++ b/Sources/Perception/Internal/UncheckedSendable.swift @@ -0,0 +1,6 @@ +@usableFromInline struct UncheckedSendable: @unchecked Sendable { + @usableFromInline let value: Value + @usableFromInline init(_ value: Value) { + self.value = value + } +} diff --git a/Sources/Perception/Internal/_PerceptionRegistrar.swift b/Sources/Perception/Internal/_PerceptionRegistrar.swift new file mode 100644 index 00000000..7cf1766b --- /dev/null +++ b/Sources/Perception/Internal/_PerceptionRegistrar.swift @@ -0,0 +1,377 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +struct _PerceptionRegistrar: Sendable { + internal class ValuePerceptionStorage { + func emit(_ element: Element) -> Bool { return false } + func cancel() {} + } + + private struct ValuesPerceptor { + private let storage: ValuePerceptionStorage + + internal init(storage: ValuePerceptionStorage) { + self.storage = storage + } + + internal func emit(_ element: Element) -> Bool { + storage.emit(element) + } + + internal func cancel() { + storage.cancel() + } + } + + private struct State: @unchecked Sendable { + private enum PerceptionKind { + case willSetTracking(@Sendable () -> Void) + case didSetTracking(@Sendable () -> Void) + case computed(@Sendable (Any) -> Void) + case values(ValuesPerceptor) + } + + private struct Perception { + private var kind: PerceptionKind + internal var properties: Set + + internal init(kind: PerceptionKind, properties: Set) { + self.kind = kind + self.properties = properties + } + + var willSetTracker: (@Sendable () -> Void)? { + switch kind { + case .willSetTracking(let tracker): + return tracker + default: + return nil + } + } + + var didSetTracker: (@Sendable () -> Void)? { + switch kind { + case .didSetTracking(let tracker): + return tracker + default: + return nil + } + } + + var perceptor: (@Sendable (Any) -> Void)? { + switch kind { + case .computed(let perceptor): + return perceptor + default: + return nil + } + } + + var isValuePerceptor: Bool { + switch kind { + case .values: + return true + default: + return false + } + } + + func emit(_ value: Element) -> Bool { + switch kind { + case .values(let perceptor): + return perceptor.emit(value) + default: + return false + } + } + + func cancel() { + switch kind { + case .values(let perceptor): + perceptor.cancel() + default: + break + } + } + } + + private var id = 0 + private var perceptions = [Int: Perception]() + private var lookups = [AnyKeyPath: Set]() + + internal mutating func generateId() -> Int { + defer { id &+= 1 } + return id + } + + internal mutating func registerTracking( + for properties: Set, willSet perceptor: @Sendable @escaping () -> Void + ) -> Int { + let id = generateId() + perceptions[id] = Perception(kind: .willSetTracking(perceptor), properties: properties) + for keyPath in properties { + lookups[keyPath, default: []].insert(id) + } + return id + } + + internal mutating func registerTracking( + for properties: Set, didSet perceptor: @Sendable @escaping () -> Void + ) -> Int { + let id = generateId() + perceptions[id] = Perception(kind: .didSetTracking(perceptor), properties: properties) + for keyPath in properties { + lookups[keyPath, default: []].insert(id) + } + return id + } + + internal mutating func registerComputedValues( + for properties: Set, perceptor: @Sendable @escaping (Any) -> Void + ) -> Int { + let id = generateId() + perceptions[id] = Perception(kind: .computed(perceptor), properties: properties) + for keyPath in properties { + lookups[keyPath, default: []].insert(id) + } + return id + } + + internal mutating func registerValues( + for properties: Set, storage: ValuePerceptionStorage + ) -> Int { + let id = generateId() + perceptions[id] = Perception( + kind: .values(ValuesPerceptor(storage: storage)), properties: properties) + for keyPath in properties { + lookups[keyPath, default: []].insert(id) + } + return id + } + + internal func valuePerceptors(for keyPath: AnyKeyPath) -> Set { + guard let ids = lookups[keyPath] else { + return [] + } + return ids.filter { perceptions[$0]?.isValuePerceptor == true } + } + + internal mutating func cancel(_ id: Int) { + if let perception = perceptions.removeValue(forKey: id) { + for keyPath in perception.properties { + if var ids = lookups[keyPath] { + ids.remove(id) + if ids.count == 0 { + lookups.removeValue(forKey: keyPath) + } else { + lookups[keyPath] = ids + } + } + } + perception.cancel() + } + } + + internal mutating func cancelAll() { + for perception in perceptions.values { + perception.cancel() + } + perceptions.removeAll() + lookups.removeAll() + } + + internal mutating func willSet(keyPath: AnyKeyPath) -> [@Sendable () -> Void] { + var trackers = [@Sendable () -> Void]() + if let ids = lookups[keyPath] { + for id in ids { + if let tracker = perceptions[id]?.willSetTracker { + trackers.append(tracker) + } + } + } + return trackers + } + + internal mutating func didSet(keyPath: KeyPath) + -> ([@Sendable (Any) -> Void], [@Sendable () -> Void]) + { + var perceptors = [@Sendable (Any) -> Void]() + var trackers = [@Sendable () -> Void]() + if let ids = lookups[keyPath] { + for id in ids { + if let perceptor = perceptions[id]?.perceptor { + perceptors.append(perceptor) + cancel(id) + } + if let tracker = perceptions[id]?.didSetTracker { + trackers.append(tracker) + } + } + } + return (perceptors, trackers) + } + + internal mutating func emit(_ value: Element, ids: Set) { + for id in ids { + if perceptions[id]?.emit(value) == true { + cancel(id) + } + } + } + } + + internal struct Context: Sendable { + private let state = _ManagedCriticalState(State()) + + internal var id: ObjectIdentifier { state.id } + + internal func registerTracking( + for properties: Set, willSet perceptor: @Sendable @escaping () -> Void + ) -> Int { + state.withCriticalRegion { $0.registerTracking(for: properties, willSet: perceptor) } + } + + internal func registerTracking( + for properties: Set, didSet perceptor: @Sendable @escaping () -> Void + ) -> Int { + state.withCriticalRegion { $0.registerTracking(for: properties, didSet: perceptor) } + } + + internal func registerComputedValues( + for properties: Set, perceptor: @Sendable @escaping (Any) -> Void + ) -> Int { + state.withCriticalRegion { $0.registerComputedValues(for: properties, perceptor: perceptor) } + } + + internal func registerValues(for properties: Set, storage: ValuePerceptionStorage) + -> Int + { + state.withCriticalRegion { $0.registerValues(for: properties, storage: storage) } + } + + internal func cancel(_ id: Int) { + state.withCriticalRegion { $0.cancel(id) } + } + + internal func cancelAll() { + state.withCriticalRegion { $0.cancelAll() } + } + + internal func willSet( + _ subject: Subject, + keyPath: KeyPath + ) { + let tracking = state.withCriticalRegion { $0.willSet(keyPath: keyPath) } + for action in tracking { + action() + } + } + + internal func didSet( + _ subject: Subject, + keyPath: KeyPath + ) { + let (ids, (actions, tracking)) = state.withCriticalRegion { + ($0.valuePerceptors(for: keyPath), $0.didSet(keyPath: keyPath)) + } + if !ids.isEmpty { + let value = subject[keyPath: keyPath] + state.withCriticalRegion { $0.emit(value, ids: ids) } + } + for action in tracking { + action() + } + for action in actions { + action(subject) + } + } + } + + private final class Extent: @unchecked Sendable { + let context = Context() + + init() { + } + + deinit { + context.cancelAll() + } + } + + internal var context: Context { + return extent.context + } + + private var extent = Extent() + + init() { + } + + /// Registers access to a specific property for observation. + /// + /// - Parameters: + /// - subject: An instance of an observable type. + /// - keyPath: The key path of an observed property. + func access( + _ subject: Subject, + keyPath: KeyPath + ) { + if let trackingPtr = _ThreadLocal.value? + .assumingMemoryBound(to: PerceptionTracking._AccessList?.self) + { + if trackingPtr.pointee == nil { + trackingPtr.pointee = PerceptionTracking._AccessList() + } + trackingPtr.pointee?.addAccess(keyPath: keyPath, context: context) + } + } + + /// A property observation called before setting the value of the subject. + /// + /// - Parameters: + /// - subject: An instance of an observable type. + /// - keyPath: The key path of an observed property. + func willSet( + _ subject: Subject, + keyPath: KeyPath + ) { + context.willSet(subject, keyPath: keyPath) + } + + /// A property observation called after setting the value of the subject. + /// + /// - Parameters: + /// - subject: An instance of an observable type. + /// - keyPath: The key path of an observed property. + func didSet( + _ subject: Subject, + keyPath: KeyPath + ) { + context.didSet(subject, keyPath: keyPath) + } + + /// Identifies mutations to the transactions registered for observers. + /// + /// This method calls ``willset(_:keypath:)`` before the mutation. Then it + /// calls ``didset(_:keypath:)`` after the mutation. + /// - Parameters: + /// - of: An instance of an observable type. + /// - keyPath: The key path of an observed property. + func withMutation( + of subject: Subject, + keyPath: KeyPath, + _ mutation: () throws -> T + ) rethrows -> T { + willSet(subject, keyPath: keyPath) + defer { didSet(subject, keyPath: keyPath) } + return try mutation() + } +} diff --git a/Sources/Perception/Perceptible.swift b/Sources/Perception/Perceptible.swift new file mode 100644 index 00000000..adcc810a --- /dev/null +++ b/Sources/Perception/Perceptible.swift @@ -0,0 +1,19 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +/// A type that emits notifications to observers when underlying data changes. +/// +/// Conforming to this protocol signals to other APIs that the type supports +/// observation. However, applying the `Perceptible` protocol by itself to a +/// type doesn't add observation functionality to the type. Instead, always use +/// the ``Perception/Perceptible()`` macro when adding observation +/// support to a type. +public protocol Perceptible {} diff --git a/Sources/Perception/PerceptionChecking.swift b/Sources/Perception/PerceptionChecking.swift new file mode 100644 index 00000000..32c67e1d --- /dev/null +++ b/Sources/Perception/PerceptionChecking.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Globally enable or disable perception checks. +/// +/// The library performs certain runtime checks to make sure that the tools are being used +/// correctly. In particular, view bodies must be wrapped in the ``WithPerceptionTracking`` view +/// in order for observation to be properly tracked. If the library detects state is accessed +/// without being inside ``WithPerceptionTracking``, a runtime warning is triggered to let you +/// know there is something to fix. +/// +/// This check only happens in `DEBUG` builds, and so does not affect App Store releases of your +/// app. However, the checks can sometimes be costly and slow down your app in development. If +/// you wish to fully disable the checks, you can set this boolean to `false.` +public var isPerceptionCheckingEnabled: Bool { + get { perceptionChecking.isPerceptionCheckingEnabled } + set { perceptionChecking.isPerceptionCheckingEnabled = newValue } +} + +public enum _PerceptionLocals { + @TaskLocal public static var isInPerceptionTracking = false + @TaskLocal public static var skipPerceptionChecking = false +} + +private let perceptionChecking = PerceptionChecking() + +private class PerceptionChecking: @unchecked Sendable { + var isPerceptionCheckingEnabled: Bool { + get { + lock.lock() + defer { lock.unlock() } + return _isPerceptionCheckingEnabled + } + set { + lock.lock() + defer { lock.unlock() } + _isPerceptionCheckingEnabled = newValue + } + } + let lock = NSLock() + #if DEBUG + var _isPerceptionCheckingEnabled = true + #else + var _isPerceptionCheckingEnabled = false + #endif +} diff --git a/Sources/Perception/PerceptionRegistrar.swift b/Sources/Perception/PerceptionRegistrar.swift new file mode 100644 index 00000000..99c7cd94 --- /dev/null +++ b/Sources/Perception/PerceptionRegistrar.swift @@ -0,0 +1,366 @@ +import Foundation +import KIssueReporting + +/// Provides storage for tracking and access to data changes. +/// +/// You don't need to create an instance of `PerceptionRegistrar` when using +/// the ``Perception/Perceptible()`` macro to indicate observability of a type. +public struct PerceptionRegistrar: Sendable { + private let _rawValue: AnySendable + #if DEBUG + private let isPerceptionCheckingEnabled: Bool + fileprivate let perceptionChecks = _ManagedCriticalState<[Location: Bool]>([:]) + #endif + + /// Creates an instance of the observation registrar. + /// + /// You don't need to create an instance of + /// ``PerceptionRegistrar`` when using the + /// ``Perception/Perceptible()`` macro to indicate observably + /// of a type. + public init(isPerceptionCheckingEnabled: Bool = KPerception.isPerceptionCheckingEnabled) { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), !isObservationBeta { + #if canImport(Observation) + self._rawValue = AnySendable(ObservationRegistrar()) + #else + self._rawValue = AnySendable(_PerceptionRegistrar()) + #endif + } else { + self._rawValue = AnySendable(_PerceptionRegistrar()) + } + #if DEBUG + self.isPerceptionCheckingEnabled = isPerceptionCheckingEnabled + #endif + } + + #if canImport(Observation) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + private var registrar: ObservationRegistrar { + self._rawValue.base as! ObservationRegistrar + } + #endif + + private var perceptionRegistrar: _PerceptionRegistrar { + self._rawValue.base as! _PerceptionRegistrar + } +} + +#if canImport(Observation) + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + extension PerceptionRegistrar { + public func access( + _ subject: Subject, keyPath: KeyPath + ) { + self.registrar.access(subject, keyPath: keyPath) + } + + public func withMutation( + of subject: Subject, keyPath: KeyPath, _ mutation: () throws -> T + ) rethrows -> T { + try self.registrar.withMutation(of: subject, keyPath: keyPath, mutation) + } + + public func willSet( + _ subject: Subject, keyPath: KeyPath + ) { + self.registrar.willSet(subject, keyPath: keyPath) + } + + public func didSet( + _ subject: Subject, keyPath: KeyPath + ) { + self.registrar.didSet(subject, keyPath: keyPath) + } + } +#endif + +extension PerceptionRegistrar { + @_disfavoredOverload + public func access( + _ subject: Subject, + keyPath: KeyPath, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + #if DEBUG && canImport(SwiftUI) + self.perceptionCheck( + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + #endif + #if canImport(Observation) + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), !isObservationBeta { + func `open`(_ subject: T) { + self.registrar.access( + subject, + keyPath: unsafeDowncast(keyPath, to: KeyPath.self) + ) + } + if let subject = subject as? any Observable { + return open(subject) + } + } + #endif + self.perceptionRegistrar.access(subject, keyPath: keyPath) + } + + @_disfavoredOverload + public func withMutation( + of subject: Subject, + keyPath: KeyPath, + _ mutation: () throws -> T + ) rethrows -> T { + #if canImport(Observation) + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), !isObservationBeta, + let subject = subject as? any Observable + { + func `open`(_ subject: S) throws -> T { + return try self.registrar.withMutation( + of: subject, + keyPath: unsafeDowncast(keyPath, to: KeyPath.self), + mutation + ) + } + return try open(subject) + } + #endif + return try self.perceptionRegistrar.withMutation(of: subject, keyPath: keyPath, mutation) + } + + @_disfavoredOverload + public func willSet( + _ subject: Subject, + keyPath: KeyPath + ) { + #if canImport(Observation) + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), !isObservationBeta, + let subject = subject as? any Observable + { + func `open`(_ subject: S) { + return self.registrar.willSet( + subject, + keyPath: unsafeDowncast(keyPath, to: KeyPath.self) + ) + } + return open(subject) + } + #endif + return self.perceptionRegistrar.willSet(subject, keyPath: keyPath) + } + + @_disfavoredOverload + public func didSet( + _ subject: Subject, + keyPath: KeyPath + ) { + #if canImport(Observation) + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), !isObservationBeta, + let subject = subject as? any Observable + { + func `open`(_ subject: S) { + return self.registrar.didSet( + subject, + keyPath: unsafeDowncast(keyPath, to: KeyPath.self) + ) + } + return open(subject) + } + #endif + return self.perceptionRegistrar.didSet(subject, keyPath: keyPath) + } +} + +extension PerceptionRegistrar: Codable { + public init(from decoder: any Decoder) throws { + self.init() + } + + public func encode(to encoder: any Encoder) { + // Don't encode a registrar's transient state. + } +} + +extension PerceptionRegistrar: Hashable { + public static func == (lhs: Self, rhs: Self) -> Bool { + // A registrar should be ignored for the purposes of determining its + // parent type's equality. + return true + } + + public func hash(into hasher: inout Hasher) { + // Don't include a registrar's transient state in its parent type's + // hash value. + } +} + +#if DEBUG && canImport(SwiftUI) + extension PerceptionRegistrar { + fileprivate func perceptionCheck( + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + if self.isPerceptionCheckingEnabled, + KPerception.isPerceptionCheckingEnabled, + !_PerceptionLocals.isInPerceptionTracking, + !_PerceptionLocals.skipPerceptionChecking, + self.isInSwiftUIBody(file: filePath, line: line) + { + reportIssue( + """ + Perceptible state was accessed but is not being tracked. Track changes to state by \ + wrapping your view in a 'WithPerceptionTracking' view. This must also be done for any \ + escaping, trailing closures, such as 'GeometryReader', `LazyVStack` (and all lazy \ + views), navigation APIs ('sheet', 'popover', 'fullScreenCover', etc.), and others. + """ + ) + } + } + + fileprivate func isInSwiftUIBody(file: StaticString, line: UInt) -> Bool { + self.perceptionChecks.withCriticalRegion { perceptionChecks in + if let result = perceptionChecks[Location(file: file, line: line)] { + return result + } + for callStackSymbol in Thread.callStackSymbols { + let mangledSymbol = callStackSymbol.utf8 + .drop(while: { $0 != .init(ascii: "$") }) + .prefix(while: { $0 != .init(ascii: " ") }) + guard let demangled = String(Substring(mangledSymbol)).demangled + else { + continue + } + if demangled.isGeometryTrailingClosure { + return !(demangled.isSuspendingClosure || demangled.isActionClosure) + } + guard + mangledSymbol.isMangledViewBodyGetter, + !demangled.isSuspendingClosure, + !demangled.isActionClosure + else { + continue + } + return true + } + perceptionChecks[Location(file: file, line: line)] = false + return false + } + } + } + + extension String { + var isGeometryTrailingClosure: Bool { + self.contains("(SwiftUI.GeometryProxy) -> ") + } + + fileprivate var isSuspendingClosure: Bool { + let fragment = self.utf8.drop(while: { $0 != .init(ascii: ")") }).dropFirst() + return fragment.starts( + with: " suspend resume partial function for closure".utf8 + ) + || fragment.starts( + with: " suspend resume partial function for implicit closure".utf8 + ) + || fragment.starts( + with: " await resume partial function for partial apply forwarder for closure".utf8 + ) + || fragment.starts( + with: " await resume partial function for partial apply forwarder for implicit closure" + .utf8 + ) + || fragment.starts( + with: " await resume partial function for implicit closure".utf8 + ) + } + fileprivate var isActionClosure: Bool { + var view = self[...].utf8 + view = view.drop(while: { $0 != .init(ascii: "#") }) + view = view.dropFirst() + view = view.drop(while: { $0 >= .init(ascii: "0") && $0 <= .init(ascii: "9") }) + view = view.drop(while: { $0 != .init(ascii: "-") }) + return view.starts(with: "-> () in ".utf8) + } + fileprivate var demangled: String? { + return self.utf8CString.withUnsafeBufferPointer { mangledNameUTF8CStr in + let demangledNamePtr = swift_demangle( + mangledName: mangledNameUTF8CStr.baseAddress, + mangledNameLength: UInt(mangledNameUTF8CStr.count - 1), + outputBuffer: nil, + outputBufferSize: nil, + flags: 0 + ) + if let demangledNamePtr = demangledNamePtr { + let demangledName = String(cString: demangledNamePtr) + free(demangledNamePtr) + return demangledName + } + return nil + } + } + } + + @_silgen_name("swift_demangle") + private func swift_demangle( + mangledName: UnsafePointer?, + mangledNameLength: UInt, + outputBuffer: UnsafeMutablePointer?, + outputBufferSize: UnsafeMutablePointer?, + flags: UInt32 + ) -> UnsafeMutablePointer? +#endif + +#if DEBUG + public func _withoutPerceptionChecking( + _ apply: () -> T + ) -> T { + return _PerceptionLocals.$skipPerceptionChecking.withValue(true) { + apply() + } + } +#else + @_transparent + @inline(__always) + public func _withoutPerceptionChecking( + _ apply: () -> T + ) -> T { + apply() + } +#endif + +#if DEBUG + extension Substring.UTF8View { + fileprivate var isMangledViewBodyGetter: Bool { + self._contains("V4bodyQrvg".utf8) + } + fileprivate func _contains(_ other: String.UTF8View) -> Bool { + guard let first = other.first + else { return false } + let otherCount = other.count + var input = self + while let index = input.firstIndex(where: { first == $0 }) { + input = input[index...] + if input.count >= otherCount, + zip(input, other).allSatisfy(==) + { + return true + } + input.removeFirst() + } + return false + } + } + + private struct Location: Hashable { + let file: String + let line: UInt + init(file: StaticString, line: UInt) { + self.file = file.description + self.line = line + } + } +#endif diff --git a/Sources/Perception/PerceptionTracking.swift b/Sources/Perception/PerceptionTracking.swift new file mode 100644 index 00000000..c28932ba --- /dev/null +++ b/Sources/Perception/PerceptionTracking.swift @@ -0,0 +1,223 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +@_spi(SwiftUI) +public struct PerceptionTracking: Sendable { + enum Id { + case willSet(Int) + case didSet(Int) + case full(Int, Int) + } + + struct Entry: @unchecked Sendable { + let context: _PerceptionRegistrar.Context + + var properties: Set + + init(_ context: _PerceptionRegistrar.Context, properties: Set = []) { + self.context = context + self.properties = properties + } + + func addWillSetPerceptor(_ changed: @Sendable @escaping () -> Void) -> Int { + return context.registerTracking(for: properties, willSet: changed) + } + + func addDidSetPerceptor(_ changed: @Sendable @escaping () -> Void) -> Int { + return context.registerTracking(for: properties, didSet: changed) + } + + func removePerceptor(_ token: Int) { + context.cancel(token) + } + + mutating func insert(_ keyPath: AnyKeyPath) { + properties.insert(keyPath) + } + + func union(_ entry: Entry) -> Entry { + Entry(context, properties: properties.union(entry.properties)) + } + } + + @_spi(SwiftUI) + public struct _AccessList: Sendable { + internal var entries = [ObjectIdentifier: Entry]() + + internal init() {} + + internal mutating func addAccess( + keyPath: PartialKeyPath, + context: _PerceptionRegistrar.Context + ) { + entries[context.id, default: Entry(context)].insert(keyPath) + } + + internal mutating func merge(_ other: _AccessList) { + entries.merge(other.entries) { existing, entry in + existing.union(entry) + } + } + } + + @_spi(SwiftUI) + public static func _installTracking( + _ tracking: PerceptionTracking, + willSet: (@Sendable (PerceptionTracking) -> Void)? = nil, + didSet: (@Sendable (PerceptionTracking) -> Void)? = nil + ) { + let values = tracking.list.entries.mapValues { + switch (willSet, didSet) { + case (.some(let willSetPerceptor), .some(let didSetPerceptor)): + return Id.full( + $0.addWillSetPerceptor { + willSetPerceptor(tracking) + }, + $0.addDidSetPerceptor { + didSetPerceptor(tracking) + }) + case (.some(let willSetPerceptor), .none): + return Id.willSet( + $0.addWillSetPerceptor { + willSetPerceptor(tracking) + }) + case (.none, .some(let didSetPerceptor)): + return Id.didSet( + $0.addDidSetPerceptor { + didSetPerceptor(tracking) + }) + case (.none, .none): + fatalError() + } + } + + tracking.install(values) + } + + @_spi(SwiftUI) + public static func _installTracking( + _ list: _AccessList, + onChange: @escaping @Sendable () -> Void + ) { + let tracking = PerceptionTracking(list) + _installTracking( + tracking, + willSet: { _ in + onChange() + tracking.cancel() + }) + } + + struct State { + var values = [ObjectIdentifier: PerceptionTracking.Id]() + var cancelled = false + } + + private let state = _ManagedCriticalState(State()) + private let list: _AccessList + + @_spi(SwiftUI) + public init(_ list: _AccessList?) { + self.list = list ?? _AccessList() + } + + internal func install(_ values: [ObjectIdentifier: PerceptionTracking.Id]) { + state.withCriticalRegion { + if !$0.cancelled { + $0.values = values + } + } + } + + public func cancel() { + let values = state.withCriticalRegion { + $0.cancelled = true + let values = $0.values + $0.values = [:] + return values + } + for (id, perceptionId) in values { + switch perceptionId { + case .willSet(let token): + list.entries[id]?.removePerceptor(token) + case .didSet(let token): + list.entries[id]?.removePerceptor(token) + case .full(let willSetToken, let didSetToken): + list.entries[id]?.removePerceptor(willSetToken) + list.entries[id]?.removePerceptor(didSetToken) + } + } + } +} + +private func generateAccessList(_ apply: () -> T) -> (T, PerceptionTracking._AccessList?) { + var accessList: PerceptionTracking._AccessList? + let result = withUnsafeMutablePointer(to: &accessList) { ptr in + let previous = _ThreadLocal.value + _ThreadLocal.value = UnsafeMutableRawPointer(ptr) + defer { + if let scoped = ptr.pointee, let previous { + if var prevList = previous.assumingMemoryBound(to: PerceptionTracking._AccessList?.self) + .pointee + { + prevList.merge(scoped) + previous.assumingMemoryBound(to: PerceptionTracking._AccessList?.self).pointee = prevList + } else { + previous.assumingMemoryBound(to: PerceptionTracking._AccessList?.self).pointee = scoped + } + } + _ThreadLocal.value = previous + } + return apply() + } + return (result, accessList) +} + +/// Tracks access to properties. +/// +/// This method tracks access to any property within the `apply` closure, and +/// informs the caller of value changes made to participating properties by way +/// of the `onChange` closure. For example, the following code tracks changes +/// to the name of cars, but it doesn't track changes to any other property of +/// `Car`: +/// +/// func render() { +/// withPerceptionTracking { +/// for car in cars { +/// print(car.name) +/// } +/// } onChange: { +/// print("Schedule renderer.") +/// } +/// } +/// +/// - Parameters: +/// - apply: A closure that contains properties to track. +/// - onChange: The closure invoked when the value of a property changes. +/// +/// - Returns: The value that the `apply` closure returns if it has a return +/// value; otherwise, there is no return value. +public func withPerceptionTracking( + _ apply: () -> T, + onChange: @autoclosure () -> @Sendable () -> Void +) -> T { + #if canImport(Observation) + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), !isObservationBeta { + return withObservationTracking(apply, onChange: onChange()) + } + #endif + + let (result, accessList) = generateAccessList(apply) + if let accessList { + PerceptionTracking._installTracking(accessList, onChange: onChange()) + } + return result +} diff --git a/Sources/Perception/WithPerceptionTracking.swift b/Sources/Perception/WithPerceptionTracking.swift new file mode 100644 index 00000000..2defc487 --- /dev/null +++ b/Sources/Perception/WithPerceptionTracking.swift @@ -0,0 +1,174 @@ +#if canImport(SwiftUI) + import SwiftUI + + /// Observes changes to perceptible models. + /// + /// Use this view to automatically subscribe to the changes of any fields in ``Perceptible()`` + /// models used in the view. Typically you will install this view at the root of your view like + /// so: + /// + /// ```swift + /// struct FeatureView: View { + /// let model: FeatureModel + /// + /// var body: some View { + /// WithPerceptionTracking { + /// // ... + /// } + /// } + /// } + /// ``` + /// + /// You will also need to use ``WithPerceptionTracking`` in any escaping, trailing closures used + /// in SwiftUI's various navigation APIs, such as the sheet modifier: + /// + /// ```swift + /// .sheet(isPresented: $isPresented) { + /// WithPerceptionTracking { + /// // Access to `model` in here will be properly observed. + /// } + /// } + /// ``` + /// + /// > Note: Other common escaping closures to be aware of: + /// > * Reader views, such as `GeometryReader`, ScrollViewReader`, etc. + /// > * Lazy views such as `LazyVStack`, `LazyVGrid`, etc. + /// > * Navigation APIs, such as `sheet`, `popover`, `fullScreenCover`, `navigationDestination`, + /// etc. + /// + /// If a field of a `@Perceptible` model is accessed in a view while _not_ inside + /// ``WithPerceptionTracking``, then a runtime warning will helpfully be triggered: + /// + /// > 🟣 Runtime Warning: Perceptible state was accessed but is not being tracked. Track changes + /// > to state by wrapping your view in a 'WithPerceptionTracking' view. This must also be done + /// > for any escaping, trailing closures, such as 'GeometryReader', `LazyVStack` (and all lazy + /// > views), navigation APIs ('sheet', 'popover', 'fullScreenCover', etc.), and others. + /// + /// To debug this, expand the warning in the Issue Navigator of Xcode (cmd+5), and click through + /// the stack frames displayed to find the line in your view where you are accessing state without + /// being inside ``WithPerceptionTracking``. + public struct WithPerceptionTracking { + @State var id = 0 + let content: () -> Content + + public var body: Content { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), !isObservationBeta { + return self.instrumentedBody() + } else { + // NB: View will not re-render when 'id' changes unless we access it in the view. + let _ = self.id + return withPerceptionTracking { + self.instrumentedBody() + } onChange: { [_id = UncheckedSendable(self._id)] in + _id.value.wrappedValue &+= 1 + } + } + } + + @_transparent + @inline(__always) + private func instrumentedBody() -> Content { + #if DEBUG + return _PerceptionLocals.$isInPerceptionTracking.withValue(true) { + self.content() + } + #else + return self.content() + #endif + } + } + + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + extension WithPerceptionTracking: AccessibilityRotorContent + where Content: AccessibilityRotorContent { + public init(@AccessibilityRotorContentBuilder content: @escaping () -> Content) { + self.content = content + } + } + + @available(iOS 14, macOS 11, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension WithPerceptionTracking: Commands where Content: Commands { + public init(@CommandsBuilder content: @escaping () -> Content) { + self.content = content + } + } + + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + extension WithPerceptionTracking: CustomizableToolbarContent + where Content: CustomizableToolbarContent { + } + + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + extension WithPerceptionTracking: Scene where Content: Scene { + public init(@SceneBuilder content: @escaping () -> Content) { + self.content = content + } + } + + @available(iOS 16, macOS 12, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension WithPerceptionTracking: TableColumnContent where Content: TableColumnContent { + public typealias TableRowValue = Content.TableRowValue + public typealias TableColumnSortComparator = Content.TableColumnSortComparator + public typealias TableColumnBody = Never + + public init(@TableColumnBuilder content: @escaping () -> Content) + where R == Content.TableRowValue, C == Content.TableColumnSortComparator { + self.content = content + } + + nonisolated public var tableColumnBody: Never { + fatalError() + } + + nonisolated public static func _makeContent( + content: _GraphValue>, inputs: _TableColumnInputs + ) -> _TableColumnOutputs { + Content._makeContent(content: content[\.body], inputs: inputs) + } + } + + @available(iOS 16, macOS 12, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + extension WithPerceptionTracking: TableRowContent where Content: TableRowContent { + public typealias TableRowValue = Content.TableRowValue + public typealias TableRowBody = Never + + public init(@TableRowBuilder content: @escaping () -> Content) + where R == Content.TableRowValue { + self.content = content + } + + nonisolated public var tableRowBody: Never { + fatalError() + } + } + + @available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) + extension WithPerceptionTracking: ToolbarContent where Content: ToolbarContent { + public init(@ToolbarContentBuilder content: @escaping () -> Content) { + self.content = content + } + } + + extension WithPerceptionTracking: View where Content: View { + public init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + } + + #if canImport(Charts) + import Charts + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension WithPerceptionTracking: ChartContent where Content: ChartContent { + public init(@ChartContentBuilder content: @escaping () -> Content) { + self.content = content + } + } + #endif +#endif diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/SharedChangeTracker.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/SharedChangeTracker.swift index b58f06d7..6bb58576 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/SharedChangeTracker.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/SharedChangeTracker.swift @@ -1,6 +1,6 @@ import CustomDump import XCTestDynamicOverlay -import ConcurrencyExtras +import KConcurrencyExtras @testable import KlaviyoSwift @testable import ComposableArchitecture diff --git a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TestStore.swift b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TestStore.swift index 9821bc33..515ac4d4 100644 --- a/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TestStore.swift +++ b/Tests/KlaviyoSwiftTests/Vendor/ComposableArchitecture/TestStore.swift @@ -1,9 +1,9 @@ @_spi(Internals) import CasePaths import Combine -import ConcurrencyExtras +import KConcurrencyExtras import CustomDump import Foundation -import IssueReporting +import KIssueReporting @_spi(Internals) @testable import KlaviyoSwift @_spi(Internals) @testable import ComposableArchitecture