Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 35 additions & 15 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// swift-tools-version:6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.
// swift-tools-version:6.2

import PackageDescription

Expand All @@ -10,50 +9,71 @@ let package = Package(
],
products: [
.library(
name: "FueledUtilsCore",
targets: ["FueledUtilsCore"]
name: "FueledCore",
targets: ["FueledCore"]
),
.library(
name: "FueledUtilsCombine",
targets: ["FueledUtilsCombine"]
name: "FueledCombine",
targets: ["FueledCombine"]
),
.library(
name: "FueledUtilsSwiftUI",
targets: ["FueledUtilsSwiftUI"]
name: "FueledSwiftUI",
targets: ["FueledSwiftUI"]
),
.library(
name: "FueledSwiftConcurrency",
targets: ["FueledSwiftConcurrency"]
),
],
dependencies: [
.package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.5"),
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.1"),
.package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.3.2"),
],
targets: [
.target(
name: "FueledUtilsCore",
name: "FueledCore",
path: "Sources/FueledUtils/Core",
linkerSettings: [
.linkedFramework("Foundation")
]
),
.target(
name: "FueledUtilsCombine",
name: "FueledCombine",
dependencies: [
"FueledUtilsCore"
"FueledCore"
],
path: "Sources/FueledUtils/Combine"
),
.target(
name: "FueledUtilsSwiftUI",
dependencies: ["FueledUtilsCombine", "FueledUtilsCore"],
name: "FueledSwiftUI",
dependencies: ["FueledCombine", "FueledCore"],
path: "Sources/FueledUtils/SwiftUI",
linkerSettings: [
.linkedFramework("SwiftUI", .when(platforms: [.iOS, .tvOS, .macOS])),
]
),
.target(
name: "FueledSwiftConcurrency",
dependencies: [
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"),
],
path: "Sources/FueledUtils/SwiftConcurrency"
),
.testTarget(
name: "FueledUtilsCombineTests",
name: "FueledCombineTests",
dependencies: [
"FueledUtilsCombine",
"FueledCombine",
],
path: "Tests/FueledUtils/CombineTests"
),
.testTarget(
name: "FueledSwiftConcurrencyTests",
dependencies: [
"FueledSwiftConcurrency"
],
path: "Tests/FueledUtils/SwiftConcurrencyTests"
),
]
)
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import Combine
import Foundation
import FueledUtilsCore
import FueledCore

extension ObservableObject where Self.ObjectWillChangePublisher == ObservableObjectPublisher {
// Perform a one-way link, where the receiver will listen for changes on the object and automatically trigger its `objectWillChange` publisher
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// limitations under the License.

import Combine
import FueledUtilsCore
import FueledCore

// MARK: - Helpers Functions
public extension Publisher {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
// limitations under the License.

import Combine
import FueledUtilsCore
import FueledCore

public typealias OptionalProtocol = FueledUtilsCore.OptionalProtocol
public typealias OptionalProtocol = FueledCore.OptionalProtocol

// swiftlint:disable generic_type_name

Expand Down
58 changes: 58 additions & 0 deletions Sources/FueledUtils/SwiftConcurrency/AsyncCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/// A concurrency-safe asynchronous cache for storing key-value pairs.
///
/// `AsyncCache` provides thread-safe caching with support for async value providers.
/// Values are computed lazily on first access and cached for subsequent retrievals.
///
/// Example usage:
/// ```swift
/// let cache = AsyncCache<String, Data>()
/// let data = try await cache.getOrAdd(key: "user-123") { key in
/// try await fetchUserData(id: key)
/// }
/// ```
public actor AsyncCache<Key: Sendable & Hashable, Value: Sendable> {
private var cachedValues: [Key: Value] = [:]

/// Creates an empty cache.
public init() {
}

/// Retrieves a value from the cache if available, or computes and caches it using the provided async provider.
///
/// - Parameters:
/// - key: The key to look up or associate with a new value.
/// - provider: An asynchronous closure that computes the value if not already cached.
/// - Returns: The cached or newly computed value.
/// - Throws: Rethrows any error thrown by the `provider` closure.
public func getOrAdd(
key: Key,
provider: @escaping @Sendable (Key) async throws -> Value
) async throws -> Value {
if let cachedValue = cachedValues[key] {
return cachedValue
}

let value = try await provider(key)
cachedValues[key] = value
return value
}

/// Removes all cached key-value pairs.
public func clear() {
cachedValues.removeAll()
}

/// Removes a specific key-value pair from the cache.
///
/// - Parameter key: The key to remove.
/// - Returns: The removed value, or `nil` if the key was not present.
@discardableResult
public func remove(key: Key) -> Value? {
cachedValues.removeValue(forKey: key)
}

/// Returns the number of cached items.
public var count: Int {
cachedValues.count
}
}
168 changes: 168 additions & 0 deletions Sources/FueledUtils/SwiftConcurrency/AsyncSemaphore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import Foundation

/// An async-compatible counting semaphore that controls access to a resource across multiple execution contexts.
///
/// Use `AsyncSemaphore` when you need to limit concurrent access to a shared resource in async/await code.
/// Unlike `DispatchSemaphore`, this semaphore suspends tasks without blocking the underlying thread.
///
/// Example usage:
/// ```swift
/// let semaphore = AsyncSemaphore(value: 3) // Allow 3 concurrent accesses
///
/// await semaphore.wait()
/// defer { semaphore.signal() }
/// // Access shared resource
/// ```
///
/// ## Topics
///
/// ### Creating a Semaphore
///
/// - ``init(value:)``
///
/// ### Signaling the Semaphore
///
/// - ``signal()``
///
/// ### Waiting for the Semaphore
///
/// - ``wait()``
/// - ``waitUnlessCancelled()``
public final class AsyncSemaphore: @unchecked Sendable {
private class Suspension: @unchecked Sendable {
enum State {
case pending
case suspendedUnlessCancelled(UnsafeContinuation<Void, Error>)
case suspended(UnsafeContinuation<Void, Never>)
case cancelled
}

var state: State

init(state: State) {
self.state = state
}
}

private var value: Int
private var suspensions: [Suspension] = []
private let _lock = NSRecursiveLock()

/// Creates a semaphore with the specified initial value.
///
/// - Parameter value: The starting value for the semaphore. Must be greater than or equal to zero.
/// - Precondition: `value` must be >= 0.
public init(value: Int) {
precondition(value >= 0, "AsyncSemaphore requires a value equal or greater than zero")
self.value = value
}

deinit {
precondition(suspensions.isEmpty, "AsyncSemaphore is deallocated while some task(s) are suspended waiting for a signal.")
}

private func lock() { _lock.lock() }
private func unlock() { _lock.unlock() }

/// Waits for, or decrements, the semaphore.
///
/// Decrements the semaphore count. If the resulting value is less than zero,
/// this method suspends the current task until ``signal()`` is called.
/// This suspension does not block the underlying thread.
public func wait() async {
lock()

value -= 1
if value >= 0 {
unlock()
return
}

await withUnsafeContinuation { continuation in
let suspension = Suspension(state: .suspended(continuation))
suspensions.insert(suspension, at: 0)
unlock()
}
}

/// Waits for, or decrements, the semaphore with cancellation support.
///
/// Decrements the semaphore count. If the resulting value is less than zero,
/// this method suspends the current task until ``signal()`` is called.
///
/// - Throws: `CancellationError` if the task is cancelled before a signal is received.
public func waitUnlessCancelled() async throws {
lock()

value -= 1
if value >= 0 {
defer { unlock() }

do {
try Task.checkCancellation()
} catch {
value += 1
throw error
}

return
}

let suspension = Suspension(state: .pending)

try await withTaskCancellationHandler {
try await withUnsafeThrowingContinuation { (continuation: UnsafeContinuation<Void, Error>) in
if case .cancelled = suspension.state {
unlock()
continuation.resume(throwing: CancellationError())
} else {
suspension.state = .suspendedUnlessCancelled(continuation)
suspensions.insert(suspension, at: 0)
unlock()
}
}
} onCancel: {
lock()

value += 1
if let index = suspensions.firstIndex(where: { $0 === suspension }) {
suspensions.remove(at: index)
}

if case let .suspendedUnlessCancelled(continuation) = suspension.state {
unlock()
continuation.resume(throwing: CancellationError())
} else {
suspension.state = .cancelled
unlock()
}
}
}

/// Signals (increments) the semaphore.
///
/// Increments the semaphore count. If there are tasks suspended in ``wait()``
/// or ``waitUnlessCancelled()``, one of them will be resumed.
///
/// - Returns: `true` if a suspended task was resumed, `false` otherwise.
@discardableResult
public func signal() -> Bool {
lock()

value += 1

switch suspensions.popLast()?.state {
case let .suspendedUnlessCancelled(continuation):
unlock()
continuation.resume()
return true
case let .suspended(continuation):
unlock()
continuation.resume()
return true
default:
unlock()
return false
}
}
}
Loading