diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml index c77e7650..ec61df01 100644 --- a/.markdownlint-cli2.yaml +++ b/.markdownlint-cli2.yaml @@ -4,7 +4,7 @@ # .markdownlint-cli2.yaml # mas # -# markdownlint-cli2 0.20.0 / markdownlint 0.40.0 +# markdownlint-cli2 0.21.0 / markdownlint 0.40.0 # --- gitignore: true diff --git a/Brewfile b/Brewfile index 06efa0c9..2a3135bb 100644 --- a/Brewfile +++ b/Brewfile @@ -2,7 +2,7 @@ brew "actionlint" # 1.7.11 brew "gh" # 2.86.0 brew "git" # 2.53.0 brew "ipsw" # 3.1.651 -brew "markdownlint-cli2" # 0.20.0 +brew "markdownlint-cli2" # 0.21.0 brew "periphery" if MacOS.version >= :sequoia && `/usr/bin/arch` == "arm64" # 3.5.1 brew "shellcheck" # 0.11.0 brew "swiftformat" # 0.59.1 diff --git a/Package.resolved b/Package.resolved index a8baffa8..38fb544a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c7eab882b82e81bf1ac9f29d8bb6656a14d1b9c3fbd25319e5ff01a7aa94d000", + "originHash" : "fe336dc5a91893be96812ec91baba97112f3407b3c398b918ac3eeebc5bc9781", "pins" : [ { "identity" : "bigint", @@ -64,15 +64,6 @@ "version" : "1.7.0" } }, - { - "identity" : "swift-async-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-async-algorithms.git", - "state" : { - "revision" : "2971dd5d9f6e0515664b01044826bcea16e59fac", - "version" : "1.1.2" - } - }, { "identity" : "swift-atomics", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 9bd38744..1ea54705 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,6 @@ _ = Package( dependencies: [ .package(url: "https://github.com/KittyMac/Sextant.git", from: "0.4.38"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.7.0"), - .package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.1.2"), .package(url: "https://github.com/apple/swift-atomics.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.3.0"), .package(url: "https://github.com/attaswift/BigInt.git", from: "5.7.0"), @@ -33,7 +32,6 @@ _ = Package( name: "mas", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "Atomics", package: "swift-atomics"), .product(name: "OrderedCollections", package: "swift-collections"), "BigInt", diff --git a/Scripts/format b/Scripts/format index a8fa492d..f708c7e7 100755 --- a/Scripts/format +++ b/Scripts/format @@ -16,9 +16,6 @@ print_notice '๐Ÿงน Formatting' "${@}" ensure_command_available markdownlint-cli2 swiftformat swiftlint -zmodload zsh/zutil -zparseopts -D -A received_flag A - export -r MAS_DISTRIBUTION=format printf -- $'--> ๐Ÿ•Šโ€‹ SwiftFormat\n' @@ -28,15 +25,8 @@ script -q /dev/null swiftformat --strict --markdown-files format-strict . | printf -- $'--> ๐Ÿฆ… SwiftLint\n' swiftlint --fix --quiet --reporter relative-path -# shellcheck disable=SC1046,SC1047,SC1072,SC1073 -if ! [[ -v 'received_flag[-A]' ]]; then - printf -- $'--> ๐Ÿ”ฌ SwiftLint Analyze\n' - # shellcheck disable=SC1036 - swiftlint analyze --fix --quiet --reporter relative-path --compiler-log-path\ - =(xcodebuild -scheme mas -destination "platform=macOS,arch=$(arch),variant=macos" 2>&1) -fi - printf -- $'--> ใ€ฝ๏ธ Markdown\n' +# shellcheck disable=SC1036 markdownlint-cli2 --fix -- ***/*.md(.) printf -- $'--> ๐Ÿšท Non-Executables\n' diff --git a/Scripts/lint b/Scripts/lint index 4be057d5..d7250172 100755 --- a/Scripts/lint +++ b/Scripts/lint @@ -10,6 +10,7 @@ # Please keep in sync with Scripts/format. # +# shellcheck disable=SC1036,SC1056,SC1072 . "${0:A:h}/_setup_script" print_notice '๐Ÿšจ Linting' "${@}" @@ -17,11 +18,11 @@ print_notice '๐Ÿšจ Linting' "${@}" ensure_command_available actionlint git markdownlint-cli2 shellcheck swiftformat swiftlint yamllint || exit [[ "$(/usr/bin/arch)" = arm64 && "${$(sw_vers -productVersion)%%.*}" -ge 15 ]] integer -r can_use_periphery="$((! ?))" -# shellcheck disable=SC1083 +# shellcheck disable=SC1073,SC1083 ((can_use_periphery)) && { ensure_command_available periphery || exit } zmodload zsh/zutil -zparseopts -D -A received_flag A P +zparseopts -D -A received_flag P export -r MAS_DISTRIBUTION=lint @@ -36,15 +37,6 @@ printf -- $'--> ๐Ÿฆ… SwiftLint\n' swiftlint --strict --quiet --reporter relative-path ((exit_status |= ${?})) -# shellcheck disable=SC1046,SC1047,SC1072,SC1073 -if ! [[ -v 'received_flag[-A]' ]]; then - printf -- $'--> ๐Ÿ”ฌ SwiftLint Analyze\n' - # shellcheck disable=SC1036 - swiftlint analyze --strict --quiet --reporter relative-path --compiler-log-path\ - =(xcodebuild -scheme mas -destination "platform=macOS,arch=$(arch),variant=macos" 2>&1) - ((exit_status |= ${?})) -fi - if ((can_use_periphery)) && ! [[ -v 'received_flag[-P]' ]]; then printf -- $'--> ๐ŸŒ€ Periphery\n' periphery scan --exclude-tests | diff --git a/Sources/mas/AppStore/AppStoreAction+download.swift b/Sources/mas/AppStore/AppStoreAction+download.swift index 8aa0d257..0ee5c3af 100644 --- a/Sources/mas/AppStore/AppStoreAction+download.swift +++ b/Sources/mas/AppStore/AppStoreAction+download.swift @@ -356,8 +356,8 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { guard let appFolderURLSubstring = standardErrorString - .matches(of: unsafe appFolderURLRegex) // swiftformat:disable:next preferKeyPath - .compactMap({ $0.1 }) // swiftlint:disable:this prefer_key_path + .matches(of: unsafe appFolderURLRegex) + .compactMap(\.1) .min(by: { $0.count < $1.count }) else { throw MASError.error( @@ -365,7 +365,7 @@ private actor DownloadQueueObserver: CKDownloadQueueObserver { error: standardErrorString, ) } - guard let appFolderURL = URL(string: String(appFolderURLSubstring)) else { + guard let appFolderURL = URL(string: .init(appFolderURLSubstring)) else { throw MASError.error( "Failed to parse app folder URL for \(appNameAndVersion) from \(appFolderURLSubstring)", error: standardErrorString, diff --git a/Sources/mas/AppStore/AppStoreAction.swift b/Sources/mas/AppStore/AppStoreAction.swift index f1881604..c1e08932 100644 --- a/Sources/mas/AppStore/AppStoreAction.swift +++ b/Sources/mas/AppStore/AppStoreAction.swift @@ -41,7 +41,7 @@ enum AppStoreAction: Sendable { withAppIDs appIDs: [AppID], force: Bool, installedApps: [InstalledApp], - lookupAppFromAppID: (AppID) async throws -> CatalogApp, + lookupAppFromAppID: @escaping @Sendable (AppID) async throws -> CatalogApp, ) async throws { try await apps( withADAMIDs: await appIDs.lookupCatalogApps(using: lookupAppFromAppID).map(\.adamID), diff --git a/Sources/mas/Commands/Home.swift b/Sources/mas/Commands/Home.swift index 1edba084..0cff4772 100644 --- a/Sources/mas/Commands/Home.swift +++ b/Sources/mas/Commands/Home.swift @@ -26,7 +26,7 @@ extension MAS { await run(lookupAppFromAppID: lookup(appID:)) } - private func run(lookupAppFromAppID: (AppID) async throws -> CatalogApp) async { + private func run(lookupAppFromAppID: @escaping @Sendable (AppID) async throws -> CatalogApp) async { await run(catalogApps: await catalogAppIDsOptionGroup.appIDs.lookupCatalogApps(using: lookupAppFromAppID)) } diff --git a/Sources/mas/Commands/Lookup.swift b/Sources/mas/Commands/Lookup.swift index f4bfb3bd..04b1b03d 100644 --- a/Sources/mas/Commands/Lookup.swift +++ b/Sources/mas/Commands/Lookup.swift @@ -27,7 +27,7 @@ extension MAS { await run(lookupAppFromAppID: lookup(appID:)) } - private func run(lookupAppFromAppID: (AppID) async throws -> CatalogApp) async { + private func run(lookupAppFromAppID: @escaping @Sendable (AppID) async throws -> CatalogApp) async { run(catalogApps: await catalogAppIDsOptionGroup.appIDs.lookupCatalogApps(using: lookupAppFromAppID)) } diff --git a/Sources/mas/Commands/MAS.swift b/Sources/mas/Commands/MAS.swift index a7a2bdf6..3706cdf0 100644 --- a/Sources/mas/Commands/MAS.swift +++ b/Sources/mas/Commands/MAS.swift @@ -54,7 +54,7 @@ struct MAS: AsyncParsableCommand, Sendable { let errorCount = printer.errorCount if errorCount > 0 { - throw ExitCode(errorCount >= UInt64(Int32.max) ? Int32.max : Int32(errorCount)) + throw ExitCode(errorCount >= UInt64(Int32.max) ? .max : .init(errorCount)) } } catch { exit(withError: error) diff --git a/Sources/mas/Commands/Open.swift b/Sources/mas/Commands/Open.swift index 16acbe74..8f7aaf63 100644 --- a/Sources/mas/Commands/Open.swift +++ b/Sources/mas/Commands/Open.swift @@ -23,7 +23,7 @@ extension MAS { @OptionGroup private var forceBundleIDOptionGroup: ForceBundleIDOptionGroup - @Argument(help: ArgumentHelp("App ID", valueName: "app-id")) + @Argument(help: .init("App ID", valueName: "app-id")) private var appIDString: String? func run() async throws { @@ -67,7 +67,7 @@ private func openMacAppStore() async throws { throw MASError.error("Failed to find app to open macappstore URLs") } - try await workspace.openApplication(at: appURL, configuration: NSWorkspace.OpenConfiguration()) + try await workspace.openApplication(at: appURL, configuration: .init()) } private func openMacAppStorePage(forAppStorePageURLString appStorePageURLString: String) async throws { diff --git a/Sources/mas/Commands/OptionGroups/CatalogAppIDsOptionGroup.swift b/Sources/mas/Commands/OptionGroups/CatalogAppIDsOptionGroup.swift index c8b3e69c..d802e64e 100644 --- a/Sources/mas/Commands/OptionGroups/CatalogAppIDsOptionGroup.swift +++ b/Sources/mas/Commands/OptionGroups/CatalogAppIDsOptionGroup.swift @@ -10,7 +10,7 @@ internal import ArgumentParser struct CatalogAppIDsOptionGroup: ParsableArguments { @OptionGroup private var forceBundleIDOptionGroup: ForceBundleIDOptionGroup - @Argument(help: ArgumentHelp("App ID", valueName: "app-id")) + @Argument(help: .init("App ID", valueName: "app-id")) private var appIDStrings: [String] var appIDs: [AppID] { diff --git a/Sources/mas/Commands/OptionGroups/InstalledAppIDsOptionGroup.swift b/Sources/mas/Commands/OptionGroups/InstalledAppIDsOptionGroup.swift index 72953081..b15d7847 100644 --- a/Sources/mas/Commands/OptionGroups/InstalledAppIDsOptionGroup.swift +++ b/Sources/mas/Commands/OptionGroups/InstalledAppIDsOptionGroup.swift @@ -10,7 +10,7 @@ internal import ArgumentParser struct InstalledAppIDsOptionGroup: ParsableArguments { @OptionGroup private var forceBundleIDOptionGroup: ForceBundleIDOptionGroup - @Argument(help: ArgumentHelp("App ID", valueName: "app-id")) + @Argument(help: .init("App ID", valueName: "app-id")) private var appIDStrings = [String]() var appIDs: [AppID] { diff --git a/Sources/mas/Commands/OptionGroups/SearchTermOptionGroup.swift b/Sources/mas/Commands/OptionGroups/SearchTermOptionGroup.swift index 8663fcc7..235e9a77 100644 --- a/Sources/mas/Commands/OptionGroups/SearchTermOptionGroup.swift +++ b/Sources/mas/Commands/OptionGroups/SearchTermOptionGroup.swift @@ -8,7 +8,7 @@ internal import ArgumentParser struct SearchTermOptionGroup: ParsableArguments { - @Argument(help: ArgumentHelp("Search terms are concatenated into a single search", valueName: "search-term")) + @Argument(help: .init("Search terms are concatenated into a single search", valueName: "search-term")) private var searchTermElements: [String] var searchTerm: String { diff --git a/Sources/mas/Commands/Outdated.swift b/Sources/mas/Commands/Outdated.swift index 88a9917b..75848fc1 100644 --- a/Sources/mas/Commands/Outdated.swift +++ b/Sources/mas/Commands/Outdated.swift @@ -27,10 +27,12 @@ extension MAS { await run(installedApps: try await installedApps.filter(!\.isTestFlight), lookupAppFromAppID: lookup(appID:)) } - private func run(installedApps: [InstalledApp], lookupAppFromAppID: (AppID) async throws -> CatalogApp) async { + private func run( + installedApps: [InstalledApp], + lookupAppFromAppID: @escaping @Sendable (AppID) async throws -> CatalogApp, + ) async { run( - outdatedApps: await outdatedApps( - from: installedApps, + outdatedApps: await installedApps.outdatedApps( filterFor: installedAppIDsOptionGroup.appIDs, lookupAppFromAppID: lookupAppFromAppID, accuracy: accuracyOptionGroup.accuracy, diff --git a/Sources/mas/Commands/Reset.swift b/Sources/mas/Commands/Reset.swift index 14c1a651..b2be9520 100644 --- a/Sources/mas/Commands/Reset.swift +++ b/Sources/mas/Commands/Reset.swift @@ -64,7 +64,7 @@ extension MAS { return } - var executablePathBuffer = [CChar](repeating: 0, count: Int(PATH_MAX)) + var executablePathBuffer = [CChar](repeating: 0, count: .init(PATH_MAX)) for pid in unsafe kinfoProcs.map(\.kp_proc.p_pid) { guard unsafe proc_pidpath(pid, &executablePathBuffer, UInt32(executablePathBuffer.count)) > 0, diff --git a/Sources/mas/Commands/Seller.swift b/Sources/mas/Commands/Seller.swift index ceabcb26..7f9425f5 100644 --- a/Sources/mas/Commands/Seller.swift +++ b/Sources/mas/Commands/Seller.swift @@ -27,7 +27,7 @@ extension MAS { await run(lookupAppFromAppID: lookup(appID:)) } - private func run(lookupAppFromAppID: (AppID) async throws -> CatalogApp) async { + private func run(lookupAppFromAppID: @escaping @Sendable (AppID) async throws -> CatalogApp) async { await run(catalogApps: await catalogAppIDsOptionGroup.appIDs.lookupCatalogApps(using: lookupAppFromAppID)) } diff --git a/Sources/mas/Commands/Uninstall.swift b/Sources/mas/Commands/Uninstall.swift index 52b29a0a..4a578a16 100644 --- a/Sources/mas/Commands/Uninstall.swift +++ b/Sources/mas/Commands/Uninstall.swift @@ -115,7 +115,7 @@ extension MAS { var uninstalledAppNSURL = NSURL?.none // swiftlint:disable:this legacy_objc_type try unsafe fileManager.trashItem( - at: URL(filePath: appPath, directoryHint: .isDirectory), + at: .init(filePath: appPath, directoryHint: .isDirectory), resultingItemURL: &uninstalledAppNSURL, ) guard let uninstalledAppPath = uninstalledAppNSURL?.path else { diff --git a/Sources/mas/Commands/Update.swift b/Sources/mas/Commands/Update.swift index 9bfe106d..5d0ebf42 100644 --- a/Sources/mas/Commands/Update.swift +++ b/Sources/mas/Commands/Update.swift @@ -30,13 +30,14 @@ extension MAS { try await run(installedApps: try await installedApps.filter(!\.isTestFlight), lookupAppFromAppID: lookup(appID:)) } - private func run(installedApps: [InstalledApp], lookupAppFromAppID: (AppID) async throws -> CatalogApp) - async throws { // swiftformat:disable:this indent + private func run( + installedApps: [InstalledApp], + lookupAppFromAppID: @escaping @Sendable (AppID) async throws -> CatalogApp, + ) async throws { try await run( outdatedApps: forceOptionGroup.force // swiftformat:disable:next indent ? installedApps.filter(for: installedAppIDsOptionGroup.appIDs).map { ($0, "") } - : await outdatedApps( - from: installedApps, + : await installedApps.outdatedApps( filterFor: installedAppIDsOptionGroup.appIDs, lookupAppFromAppID: lookupAppFromAppID, accuracy: accuracyOptionGroup.accuracy, diff --git a/Sources/mas/Controllers/CatalogApp+ITunesSearch.swift b/Sources/mas/Controllers/CatalogApp+ITunesSearch.swift index 5331a3c3..009c6a2a 100644 --- a/Sources/mas/Controllers/CatalogApp+ITunesSearch.swift +++ b/Sources/mas/Controllers/CatalogApp+ITunesSearch.swift @@ -5,7 +5,6 @@ // Copyright ยฉ 2018 mas-cli. All rights reserved. // -private import AsyncAlgorithms internal import Foundation private import Sextant private import SwiftSoup @@ -33,7 +32,7 @@ func lookup( let queryItem = switch appID { case let .adamID(adamID): - URLQueryItem(name: "id", value: String(adamID)) + URLQueryItem(name: "id", value: .init(adamID)) case let .bundleID(bundleID): URLQueryItem(name: "bundleId", value: bundleID) } @@ -63,7 +62,7 @@ private extension CatalogApp { do { return try await URL(string: appStorePageURLString) .flatMap { url in // swiftformat:disable indent - try SwiftSoup.parse(try await dataFrom(url).0, appStorePageURLString) + try unsafe SwiftSoup.parse(try await dataFrom(url).0, appStorePageURLString) .select("#serialized-server-data") .first()? .data() @@ -109,9 +108,7 @@ func search( dataFrom: dataSource, ) .filter { ($0.supportedDevices?.contains("MacDesktop-MacDesktop") ?? false) && !adamIDSet.contains($0.adamID) } - .async - .map { $0.with(minimumOSVersion: await $0.minimumOSVersion(dataFrom: dataSource)) } - .array, + .concurrentMap { $0.with(minimumOSVersion: await $0.minimumOSVersion(dataFrom: dataSource)) }, ) { $0.name.similarity(to: searchTerm) } } @@ -142,7 +139,7 @@ async throws -> [CatalogApp] { // swiftformat:disable:this indent do { return try JSONDecoder().decode(CatalogAppResults.self, from: data).results } catch { - throw MASError.error("Failed to parse JSON from response \(url)", error: String(data: data, encoding: .utf8) ?? "") + throw MASError.error("Failed to parse JSON from response \(url)", error: .init(data: data, encoding: .utf8) ?? "") } } diff --git a/Sources/mas/Models/AppID.swift b/Sources/mas/Models/AppID.swift index 1c65b6a5..8c6b0adf 100644 --- a/Sources/mas/Models/AppID.swift +++ b/Sources/mas/Models/AppID.swift @@ -33,8 +33,9 @@ enum AppID: CustomStringConvertible, Sendable { } extension [AppID] { // swiftlint:disable:this file_types_order - func lookupCatalogApps(using lookupAppFromAppID: (AppID) async throws -> CatalogApp) async -> [CatalogApp] { - await compactMap(attemptingTo: "lookup app for", lookupAppFromAppID) + func lookupCatalogApps(using lookupAppFromAppID: @escaping @Sendable (AppID) async throws -> CatalogApp) + async -> [CatalogApp] { // swiftformat:disable:this indent + await concurrentCompactMap(attemptingTo: "lookup app for", lookupAppFromAppID) } } diff --git a/Sources/mas/Models/OutdatedApp.swift b/Sources/mas/Models/OutdatedApp.swift index 2df12992..bde88c86 100644 --- a/Sources/mas/Models/OutdatedApp.swift +++ b/Sources/mas/Models/OutdatedApp.swift @@ -76,71 +76,44 @@ private extension InstalledApp { : false } ?? ( // swiftformat:disable indent - UniversalSemVer(from: version).compareSemVerAndBuild(to: UniversalSemVer(from: catalogApp.version)) + UniversalSemVer(from: version).compareSemVerAndBuild(to: .init(from: catalogApp.version)) == .orderedAscending ) } // swiftformat:enable indent } -func outdatedApps( - from installedApps: [InstalledApp], - filterFor appIDs: [AppID], - lookupAppFromAppID: (AppID) async throws -> CatalogApp, - accuracy: OutdatedAccuracy, - shouldWarnIfUnknownApp: Bool, -) async -> [OutdatedApp] { - accuracy == .inaccurate - ? await installedApps // swiftformat:disable indent - .filter(for: appIDs) - .compactMap { installedApp in - do { - let catalogApp = try await lookupAppFromAppID(.bundleID(installedApp.bundleID)) - if installedApp.isOutdated(comparedTo: catalogApp) { - return OutdatedApp(installedApp, catalogApp.version) - } - } catch { - error.print(forExpectedAppName: installedApp.name, shouldWarnIfUnknownApp: shouldWarnIfUnknownApp) - } - return nil - } - : await withTaskGroup { group in // swiftformat:enable indent - func filterOutUnknownApps(from installedApps: [InstalledApp]) async -> [InstalledApp] { - accuracy != .accurateIgnoreUnknownApps - ? installedApps // swiftformat:disable:this indent - : await installedApps.compactMap { installedApp in +extension [InstalledApp] { + func outdatedApps( + filterFor appIDs: [AppID], + lookupAppFromAppID: @escaping @Sendable (AppID) async throws -> CatalogApp, + accuracy: OutdatedAccuracy, + shouldWarnIfUnknownApp: Bool, + ) async -> [OutdatedApp] { + switch accuracy { + case .accurate: + await filter(for: appIDs).concurrentCompactMap { await $0.outdated } + case .accurateIgnoreUnknownApps: + await filter(for: appIDs).concurrentCompactMap { installedApp in do { _ = try await lookupAppFromAppID(.bundleID(installedApp.bundleID)) - return installedApp + return await installedApp.outdated } catch { error.print(forExpectedAppName: installedApp.name, shouldWarnIfUnknownApp: shouldWarnIfUnknownApp) return nil } } - } - let installedApps = await filterOutUnknownApps(from: installedApps.filter(for: appIDs)) - let maxConcurrentTaskCount = min(installedApps.count, 16) - var index = 0 - while index < maxConcurrentTaskCount { - let installedApp = installedApps[index] - index += 1 - group.addTask { - await installedApp.outdated - } - } - - return await group.reduce(into: [OutdatedApp]()) { outdatedApps, outdatedApp in - if let outdatedApp { - outdatedApps.append(outdatedApp) - } - - guard index < installedApps.count else { - return + case .inaccurate: + await filter(for: appIDs).concurrentCompactMap { installedApp in + do { + let catalogApp = try await lookupAppFromAppID(.bundleID(installedApp.bundleID)) + if installedApp.isOutdated(comparedTo: catalogApp) { + return OutdatedApp(installedApp, catalogApp.version) + } + } catch { + error.print(forExpectedAppName: installedApp.name, shouldWarnIfUnknownApp: shouldWarnIfUnknownApp) + } + return nil } - - let installedApp = installedApps[index] - index += 1 - _ = group.addTaskUnlessCancelled { await installedApp.outdated } } } - .sorted(using: KeyPathComparator(\.installedApp.name, comparator: .localizedStandard)) } diff --git a/Sources/mas/Utilities/AsyncSequence.swift b/Sources/mas/Utilities/AsyncSequence.swift deleted file mode 100644 index 8f32fdb4..00000000 --- a/Sources/mas/Utilities/AsyncSequence.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// AsyncSequence.swift -// mas -// -// Copyright ยฉ 2026 mas-cli. All rights reserved. -// - -extension AsyncSequence where Self: Sendable { - var array: [Element] { - get async throws { - try await reduce(into: [Element]()) { $0.append($1) } - } - } -} diff --git a/Sources/mas/Utilities/Collection.swift b/Sources/mas/Utilities/Collection.swift index 954aa0ed..fe95b2bc 100644 --- a/Sources/mas/Utilities/Collection.swift +++ b/Sources/mas/Utilities/Collection.swift @@ -11,60 +11,74 @@ extension Collection { } } -extension Collection { - func compactMap(_ transform: (Element) async throws(E) -> T?) async throws(E) -> [T] { - var transformedElements = [T]() - transformedElements.reserveCapacity(count) - return try await compactMap(into: &transformedElements, transform) +extension Collection where Element: Sendable { + func concurrentMap( + maxConcurrentTaskCount: Int = defaultMaxConcurrentTaskCount, + _ transform: @escaping @Sendable (Element) async -> T, + ) async -> [T] { // swiftlint:disable:next force_unwrapping + await concurrentTransform(maxConcurrentTaskCount: maxConcurrentTaskCount, transform).map { $0! } } - private func compactMap( - into transformedElements: inout [T], - _ transform: (Element) async throws(E) -> T?, - ) async throws(E) -> [T] { - for element in self { - try await transform(element).map { transformedElements.append($0) } - } - return transformedElements - } -} + func concurrentMap( + maxConcurrentTaskCount: Int = defaultMaxConcurrentTaskCount, + _ transform: @escaping @Sendable (Element) async throws -> T, + ) async rethrows -> [T] { // periphery:ignore + try await concurrentTransform(maxConcurrentTaskCount: maxConcurrentTaskCount, transform).map { $0! } + } // swiftlint:disable:previous force_unwrapping -extension Collection { - func compactMap(attemptingTo effect: String, _ transform: (Element) async throws(E) -> T?) async -> [T] { - await compactMap(transform) { MAS.printer.error($1 is MASError ? [] : ["Failed to", effect, $0], error: $1) } + func concurrentCompactMap( + maxConcurrentTaskCount: Int = defaultMaxConcurrentTaskCount, + _ transform: @escaping @Sendable (Element) async -> T?, + ) async -> [T] { + await concurrentTransform(maxConcurrentTaskCount: maxConcurrentTaskCount, transform).compactMap(\.self) } - private func compactMap( - _ transform: (Element) async throws(E) -> T?, - handlingErrors errorHandler: (Element, E) async -> Void, - ) async -> [T] { - await compactMap(transform) { element, error in - await errorHandler(element, error) - return nil - } + func concurrentCompactMap( + maxConcurrentTaskCount: Int = defaultMaxConcurrentTaskCount, + _ transform: @escaping @Sendable (Element) async throws -> T?, + ) async rethrows -> [T] { // periphery:ignore + try await concurrentTransform(maxConcurrentTaskCount: maxConcurrentTaskCount, transform).compactMap(\.self) } - private func compactMap( - _ transform: (Element) async throws(E) -> T?, - handlingErrors errorHandler: (Element, E) async -> T?, + func concurrentCompactMap( + attemptingTo perform: String, + maxConcurrentTaskCount: Int = defaultMaxConcurrentTaskCount, + _ transform: @escaping @Sendable (Element) async throws(E) -> T?, ) async -> [T] { - await compactMap { element in + await concurrentCompactMap(maxConcurrentTaskCount: maxConcurrentTaskCount) { element in do { return try await transform(element) - } catch let error as E { - return await errorHandler(element, error) } catch { - fatalError( - """ - Impossible error type \(type(of: error)) for element: - \(element) + MAS.printer.error(error is MASError ? [] : ["Failed to", perform, element], error: error) + return nil + } + } + } - Error: - \(error) + private func concurrentTransform( + maxConcurrentTaskCount: Int, + _ transform: @escaping @Sendable (Element) async throws -> T?, + ) async rethrows -> [T?] { + try await withThrowingTaskGroup(of: (index: Int, result: T?).self) { group in + var iterator = enumerated().makeIterator() + func addNextTask() { + if let next = iterator.next() { + group.addTask { + (next.offset, try await transform(next.element)) + } + } + } - """, - ) + for _ in 0..(attemptTo effect: String, _ body: (Element) async throws(E) -> Void) async { - await forEach(body) { MAS.printer.error($1 is MASError ? [] : ["Failed to", effect, $0], error: $1) } + func forEach(attemptTo perform: String, _ body: (Element) async throws(E) -> Void) async { + await forEach(body) { MAS.printer.error($1 is MASError ? [] : ["Failed to", perform, $0], error: $1) } } private func forEach( diff --git a/Tests/MASTests/Models/MASTests+CatalogApp.swift b/Tests/MASTests/Models/MASTests+CatalogApp.swift index c6508a24..9aefca90 100644 --- a/Tests/MASTests/Models/MASTests+CatalogApp.swift +++ b/Tests/MASTests/Models/MASTests+CatalogApp.swift @@ -13,7 +13,7 @@ private extension MASTests { @Test func parsesCatalogAppFromThingsThatGoBumpJSON() { let actual = consequencesOf( - try JSONDecoder().decode(CatalogApp.self, from: Data(fromResource: "things-lookup")).adamID, + try JSONDecoder().decode(CatalogApp.self, from: .init(fromResource: "things-lookup")).adamID, ) let expected = Consequences(1_472_954_003 as ADAMID) #expect(actual == expected) diff --git a/Tests/MASTests/Models/MASTests+CatalogAppResults.swift b/Tests/MASTests/Models/MASTests+CatalogAppResults.swift index ba810287..a02597f8 100644 --- a/Tests/MASTests/Models/MASTests+CatalogAppResults.swift +++ b/Tests/MASTests/Models/MASTests+CatalogAppResults.swift @@ -13,7 +13,7 @@ private extension MASTests { @Test func parsesCatalogAppResultsFromBBEditJSON() { let actual = - consequencesOf(try JSONDecoder().decode(CatalogAppResults.self, from: Data(fromResource: "bbedit")).resultCount) + consequencesOf(try JSONDecoder().decode(CatalogAppResults.self, from: .init(fromResource: "bbedit")).resultCount) let expected = Consequences(1) #expect(actual == expected) } @@ -21,7 +21,7 @@ private extension MASTests { @Test func parsesCatalogAppResultsFromThingsJSON() { let actual = - consequencesOf(try JSONDecoder().decode(CatalogAppResults.self, from: Data(fromResource: "things")).resultCount) + consequencesOf(try JSONDecoder().decode(CatalogAppResults.self, from: .init(fromResource: "things")).resultCount) let expected = Consequences(12) #expect(actual == expected) }