maniramezan/swiftcommons
Shared Swift utilities and helpers for reusable app code.
Requirements
- Swift 6.2 toolchain
- macOS 15+, iOS 18+, Mac Catalyst 18+ (the package's declared minimums; they apply whether or
not the SwiftData trait is enabled)
Installation
Add SwiftCommons as a Swift Package Manager dependency:
dependencies: [
.package(url: "https://github.com/maniramezan/SwiftCommons.git", from: "0.12.0")
]SwiftCommons ships three products:
SwiftCommons— the main library. Add this to any target that uses the APIs below.SwiftCommonsTestSupport— test-only helpers (a TestCommons-backed fake clock,LoadingStateassertions,
in-memory SwiftData context, sync test fixtures). Add this to your test targets only.
SwiftCommonsSpeech— on-device speech-to-text, text-to-speech, and read-aloud transcript
alignment (see Speech). A separate product so apps that don't use speech don't link Speech or AVFAudio. Live recognition needs iOS 26 / macOS 26.
.target(name: "MyApp", dependencies: [
.product(name: "SwiftCommons", package: "SwiftCommons")
]),
.testTarget(name: "MyAppTests", dependencies: [
"MyApp",
.product(name: "SwiftCommons", package: "SwiftCommons"),
.product(name: "SwiftCommonsTestSupport", package: "SwiftCommons"),
]),Package traits
| Trait | Default | Enables | |---|---|---| | SwiftData | on | ModelContainer.make(for:inMemory:), SyncEngine, SyncResourceAdapter, SyncMetadata, and the SwiftData test helpers (makeInMemoryModelContext, makeInMemorySyncContainer) | | CSV | off | CSV.parseRows / CSV.serializeRows |
With no traits: argument you get the defaults, so the SwiftData APIs are included. A package that doesn't use SwiftData (for example, a networking library) can leave it out and keep everything else, including the sync DTOs, SyncableModel, SyncState, and SyncEvent:
.package(url: "https://github.com/maniramezan/SwiftCommons.git", from: "0.12.0", traits: [])Listing traits replaces the defaults instead of adding to them. To add CSV and keep SwiftData, include .defaults:
.package(
url: "https://github.com/maniramezan/SwiftCommons.git", from: "0.12.0",
traits: [.defaults, "CSV"])Features
- Extensions — safe/defaulted
Arraysubscripts,Optional.ifNil(_:),Stringblank/trim
helpers, the StringParsable protocol (T(parsing:) with a lenient Bool parser) and String(flag:) boolean-flag helpers, Bundle app-version accessors, UUID byte/deterministic helpers, Duration.timeInterval, URL string-literal support, and locale-aware NumberFormatter/FixedWidthInteger helpers.
- Decoding diagnostics —
DecodingError.debugSummary(e.g.
keyNotFound 'id' at items[2].user) for logging decode failures without payload values.
- Dates & calendars —
Calendardate math (start of week/month, inclusive date ranges, month
arithmetic), a thread-safe DateFormatter cache, and Date.relativeDescription(...) for human-readable relative times ("1 hour ago").
- Localization —
Language/Countryenums andLocaleidentifier helpers. - Logging — OSLog
Loggerconveniences for categories, privacy, and context. - Library logging —
LibraryLogger, a level-gated OSLog front end whose verbosity a library's
users control (setLevel(_:)), with per-category handles and private URL/error logging.
- Formatting —
DurationFormatterfor compactm:ss/h:mm:ssdurations. - Errors —
AnySendableError, aHashablesnapshot of any error (type, domain, code,
descriptions) for storing or comparing errors as values.
- State — a generic
LoadingStatemachine (idle/loading/loaded/failed) with a
user-safe LoadingError, including a LoadingState.load { ... } helper for wrapping async work. LoadingErrorKind and LoadingErrorClassifiable distinguish offline, timeout, and other failures without displaying internal error details; apps can map LoadingError.kind to localized copy.
- App versions —
AppVersion(string:)parses and compares numeric marketing versions,
normalizing missing components to zero and ignoring prerelease/build suffixes.
- Month grids —
CalendarArithmetic.gridDates(in:)returns day starts padded to full weeks,
respecting the calendar's first weekday, time zone, leap months, and era segments.
- Configuration —
ConfigValue, a lenientbool/string/int/doublecoercion type, with
loaders for ProcessInfo.environment and decoded property lists, plus a ConfigValueType tag and ConfigValue(string:valueType:) for decoding a textual value whose type is known separately.
- Async fan-out —
AsyncBroadcaster, one source broadcast to manyAsyncStreams, with
optional replay of the latest value.
- Concurrency —
AsyncLock(FIFO mutex),AsyncSemaphore(counting semaphore),Debouncer,
and withRetry(...) — all built on the injectable DelayClock abstraction so consumers can substitute a fake clock in tests.
- Retry backoff —
RetryBackoff(.constant,.exponentialwith cap and jitter) and a
shouldRetry predicate for withRetry(attempts:backoff:clock:shouldRetry:operation:).
- Timeouts —
withTimeout(_:clock:operation:)races async work against an injectable clock,
cancels the losing task, and throws TimeoutError when time expires. Operations must cooperate with cancellation for prompt completion.
- CSV — lightweight CSV parsing/serialization (behind the
CSVpackage trait). - Call coalescing —
SingleFlight<Key, Value>, which shares one in-flight async call among
concurrent callers for the same key.
- Persistence —
ModelContainer.make(for:inMemory:), a thin SwiftData bootstrap helper for
apps, previews, and tests.
- Sync — a generic
SyncEnginethat drives offline, cross-device sync for SwiftData-backed
resources through one contract-owning loop; resources plug in as SyncResourceAdapter values.
Usage
Collections & optionals
import SwiftCommons
let value = [1, 2, 3][safe: 10] // nil — no crash
let fallback = ["a", "b"][5, default: "n/a"] // "n/a"
let slice = [1, 2, 3][safe: 1..<10] // [2, 3]
let name: String? = nil
let resolved = name.ifNil("Anonymous") // "Anonymous"Strings, Bundle, and UUID
" hi ".trimmed // "hi"
" ".nilIfBlank // nil
Bool(parsing: "yes") // true — also accepts 1/0, on/off, true/false
Int(parsing: "42") // 42 — T(parsing:) works for any StringParsable
String(flag: true) // "1"
Bundle.main.versionAndBuildNumber // "2.3.1 (142)"
UUID().bytes // [UInt8], canonical byte orderNumbers & currency
1234.digits // [4, 3, 2, 1]
NumberFormatter.formatDecimal(1234.5, fractionDigits: 2...2) // "1,234.50" in en_US
NumberFormatter.formatPercent(0.25) // "25%" in en_US
NumberFormatter.formatYear(2024) // "2024" (digits follow the locale)
NumberFormatter.formatCurrency(9.99, currencyCode: "USD") // "$9.99"Dates & calendars
let formatter = DateFormatter.formatter(.MMMMddyyyy) // cached per thread
let dateAndTime = DateFormatter.formatter(dateStyle: .medium, timeStyle: .short)
let monthLabel = DateFormatter.formatter(template: "yMMMM", calendar: .current)
// Cached formatters are shared on the calling thread: treat them as read-only
// and use them synchronously without passing them between tasks or threads.
let dateString = formatter.string(from: Date())
let calendar = Calendar(identifier: .gregorian)
let monthStart = try calendar.startOfMonth(for: Date())
let days = try calendar.numberOfDays(in: 2, year: 2024) // 29
Date().addingTimeInterval(-3600).relativeDescription() // "1 hour ago"Localization
let id = Locale.identifier(language: .french, country: .canada) // "fr-CA"
let preset = Locale.Identifiers.englishUS // "en-US"
let persian = Locale(identifier: "fa_IR").withNumberingSystemIdentifier(.arabExtended)Logging
let logger = Logger.swiftCommonsLogger(for: MyType.self)
logger.infoPublic("Started")
logger.error("Failed to load", error: error, context: "id=\(id)")Signposts
SignpostRecorder wraps OSSignposter for measuring work that has to fit inside a frame — scrolling, animation, rendering. Recording is free when no profiler is attached.
let signposts = SignpostRecorder(subsystem: "MyApp", category: "Rendering")
let grid = signposts.measure("resolveGrid") { expensiveGridResolution() }
// Start and end driven by separate callbacks
let state = signposts.begin("scrollSettle")
signposts.end("scrollSettle", state)Record with Instruments' os_signpost instrument, or xcrun xctrace record --template 'os_signpost' --attach <pid>.
State & configuration
var state: LoadingState<[Item]> = await .load { try await api.fetchItems() }
if let items = state.value { /* ... */ }
if let error = state.error { showError(error.message, retryable: error.isRetryable) }
let config = ConfigValue.environment()
let isDebug = config["DEBUG_MODE"]?.boolValue ?? false
// Decode a textual value when its type is known separately
ConfigValue(string: "yes", valueType: .bool) // .bool(true)
ConfigValue(string: "7.5", valueType: .int) // nilConcurrency
let data = try await withRetry(attempts: 3, delay: .seconds(1)) {
try await fetchData()
}
let debouncer = Debouncer(delay: .milliseconds(300))
func searchFieldDidChange(_ query: String) async {
await debouncer.run { await performSearch(query) }
}
let lock = AsyncLock() // serialize work that spans awaits
let result = try await lock.withLock { try await refreshToken() }
let semaphore = AsyncSemaphore(value: 3) // cap concurrent requests
let response = try await semaphore.withPermit { try await urlSession.data(from: url) }Timeouts, versions, and month grids
let items = try await withTimeout(.seconds(10)) {
try await fetchItems()
}
let installedVersion = AppVersion(string: "2.4")
let recommendedVersion = AppVersion(string: "2.5.0-preview")
let arithmetic = CalendarArithmetic(calendar: Calendar(identifier: .gregorian))
let dates = arithmetic.gridDates(in: MonthIdentifier(month: 2, year: 2028))AppVersion compares numeric app versions; it does not implement semantic-version prerelease precedence. gridDates(in:) returns nil for an invalid or mismatched month and includes padding dates outside the requested month. Use arithmetic.month(containing:) to identify that padding.
withTimeout waits for both child tasks to finish cleanup. An operation that ignores cancellation can keep it suspended beyond the timeout. Parent cancellation throws CancellationError.
LoadingError(from:) recognizes errors conforming to LoadingErrorClassifiable, native offline and timeout URL errors, and TimeoutError. Other errors retain the generic safe message. For localized presentation, map LoadingError.kind to app-owned strings; explicit messages remain available through LoadingError(message:isRetryable:requiresSignIn:kind:).
Speech
SwiftCommonsSpeech wraps Apple's on-device SpeechAnalyzer and AVSpeechSynthesizer behind small protocols (SpeechRecognizing, SpeechSynthesizing) so features can be tested with fakes.
import SwiftCommonsSpeech
// 1. Permissions (Info.plist needs NSMicrophoneUsageDescription and
// NSSpeechRecognitionUsageDescription) and the locale's model.
guard await SpeechRecognitionPermissions.requestAll() else { return }
let locale = Locale(identifier: "en_US")
if await SpeechModelAssets.status(for: locale) != .installed {
try await SpeechModelAssets.install(for: locale) { progress in print(progress) }
}
// 2. Live transcript; on iOS, activate a .playAndRecord AVAudioSession first.
let recognizer = SpeechRecognitionSession()
let events = try await recognizer.start(
SpeechRecognitionConfiguration(locale: locale, autoStopAfterSilence: .seconds(1.5)))
var transcript = SpeechTranscript()
for try await event in events {
if case .transcript(let latest) = event { transcript = latest }
}
// 3. Speak a reply with the best installed voice.
let speech = SpeechSynthesisSession()
for await _ in speech.speak(SpeechSynthesisRequest(text: "Nice work!", languageCode: "en-US")) {}
// 4. Grade a read-aloud attempt word by word.
let alignment = TranscriptAlignment.align(
reference: "The quick brown fox", heard: transcript.words.map(\.transcriptToken))
alignment.tokens // .match / .lowConfidence / .substitution / .omission / .insertion
alignment.score // 0...1Alignment compares what the recognizer understood: it catches missed, extra, and misheard words, but a recognizer often repairs mildly mispronounced words, so it isn't phoneme-level pronunciation scoring. Leave contextualStrings empty when grading, or the recognizer is biased toward the expected sentence.
Sync (SwiftData)
SyncEngine drives offline sync for SwiftData rows that conform to SyncableModel. Each resource plugs in with one SyncResourceAdapter (a struct of closures); the engine owns the ack guard, pending guard, pagination, full-snapshot reconciliation, and full-resync recovery.
@Model final class Item: SyncableModel {
var key: String
var title: String
var serverId: Int?
var updatedAt: Date
var isTombstoned: Bool // never name this `isDeleted` on a @Model
var syncState: SyncState
var localUpdatedAt: Date
// init ...
}
// SyncMetadata stores each resource's cursor — it must be in your schema.
let container = try ModelContainer.make(for: Item.self, SyncMetadata.self)
let engine = SyncEngine(modelContainer: container, events: { analytics.track($0) })
let items = SyncResourceAdapter<Item, ItemUpsert, ItemDelete, ItemChange>(
resourceName: "items",
fetchPending: { try $0.fetch(FetchDescriptor<Item>(predicate: #Predicate { $0.syncState != .synced })) },
businessKey: { $0.key },
makeUpserts: { $0.filter { !$0.isTombstoned }.map(ItemUpsert.init) },
makeDeletes: { $0.filter(\.isTombstoned).map(ItemDelete.init) },
call: { try await api.sync("items", $0) },
findExisting: { change, context in /* fetch by change.key */ },
changeKey: { $0.key },
isChangeDeleted: { $0.isDeleted },
upsertFromChange: { change, existing, context in /* apply or insert */ },
fetchActive: { try $0.fetch(FetchDescriptor<Item>(predicate: #Predicate { !$0.isTombstoned })) },
purgeSynced: { try $0.delete(model: Item.self, where: #Predicate { $0.syncState == .synced }) }
)
try await engine.syncAll([AnySyncResource(items)])SyncMetadata uses @Attribute(.unique), which CloudKit-backed containers don't support — keep it in a local (non-CloudKit) store. See the Syncing SwiftData Resources DocC article for the full contract.
Persistence (SwiftData)
let container = try ModelContainer.make(for: Item.self, Tag.self)
let previewContainer = try ModelContainer.make(for: Item.self, inMemory: true)Testing your own code
Add the SwiftCommonsTestSupport product to your test target to get:
import SwiftCommons
import SwiftCommonsTestSupport
import Testing
let clock = ManualClock()
let debouncer = Debouncer(delay: .seconds(1), clock: clock)
await debouncer.run { /* action under test */ }
try #require(await clock.waitForSleepCount(1))
await clock.advance(by: .seconds(1)) // resumes the pending action deterministically
let context = try makeInMemoryModelContext(for: Item.self)
let state = await LoadingState.load { try await fetchItems() }
expectLoaded(state) // records a test failure if `state` isn't `.loaded`
// Sync: a container that already includes SyncMetadata, plus DTO builders.
let container = try makeInMemorySyncContainer(for: Item.self)
let response = SyncResponseDTO<ItemChange>.fixture(
applied: [.fixture(key: "run", id: 101, status: "created")], cursor: "c1")Agent skills
The repo ships Agent Skills that teach coding agents (Claude Code, Codex, Cursor, …) to use SwiftCommons correctly in your app. Install them into a consuming project:
npx skills add maniramezan/SwiftCommons # all three
npx skills add maniramezan/SwiftCommons --skill swiftcommons-syncswiftcommons— "use this instead of hand-rolling it" API map, logging conventions, pitfalls.swiftcommons-sync— adding aSyncEngineresource end to end.swiftcommons-testing—SwiftCommonsTestSupportpatterns (fake clock, fixtures).
Documentation
Full API documentation is published with DocC to GitHub Pages.
Generate documentation locally:
swift package generate-documentation \
--target SwiftCommons \
--output-path /tmp/doccPackage Metadata
Repository: maniramezan/swiftcommons
Default branch: main
README: README.md