Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
10 changes: 7 additions & 3 deletions Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,22 +252,26 @@ macOS ONLY: There is a special selector for swiftly to use your Xcode toolchain.
Remove an installed toolchain.

```
swiftly uninstall <toolchain> [--assume-yes] [--verbose] [--version] [--help]
swiftly uninstall <toolchains>... [--assume-yes] [--verbose] [--version] [--help]
```

**toolchain:**
**toolchains:**

*The toolchain(s) to uninstall.*


The toolchain selector provided determines which toolchains to uninstall. Specific toolchains can be uninstalled by using their full names as the selector, for example a full stable release version with patch (a.b.c):
The list of toolchain selectors determines which toolchains to uninstall. Specific toolchains can be uninstalled by using their full names as the selector, for example a full stable release version with patch (a.b.c):

$ swiftly uninstall 5.2.1

Or a full snapshot name with date (a.b-snapshot-YYYY-mm-dd):

$ swiftly uninstall 5.7-snapshot-2022-06-20

Multiple toolchain selectors can uninstall multiple toolchains at once:

$ swiftly uninstall 5.2.1 6.0.1

Less specific selectors can be used to uninstall multiple toolchains at once. For instance, the patch version can be omitted to uninstall all toolchains associated with a given minor version release:

$ swiftly uninstall 5.6
Expand Down
298 changes: 241 additions & 57 deletions Sources/Swiftly/Uninstall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,24 @@ struct Uninstall: SwiftlyCommand {
abstract: "Remove an installed toolchain."
)

private enum UninstallConstants {
static let allSelector = "all"
}

private struct UninstallCancelledException: Error {}

private struct ToolchainSelectionResult {
let validToolchains: Set<ToolchainVersion>
let selectorToToolchains: [String: [ToolchainVersion]]
let invalidSelectors: [String]
let noMatchSelectors: [String]
}

@Argument(help: ArgumentHelp(
"The toolchain(s) to uninstall.",
discussion: """

The toolchain selector provided determines which toolchains to uninstall. Specific \
The list of toolchain selectors determines which toolchains to uninstall. Specific \
toolchains can be uninstalled by using their full names as the selector, for example \
a full stable release version with patch (a.b.c):

Expand All @@ -20,6 +33,10 @@ struct Uninstall: SwiftlyCommand {

$ swiftly uninstall 5.7-snapshot-2022-06-20

Multiple toolchain selectors can uninstall multiple toolchains at once:

$ swiftly uninstall 5.2.1 6.0.1

Less specific selectors can be used to uninstall multiple toolchains at once. For instance, \
the patch version can be omitted to uninstall all toolchains associated with a given minor version release:

Expand All @@ -39,7 +56,7 @@ struct Uninstall: SwiftlyCommand {
$ swiftly uninstall all
"""
))
var toolchain: String
var toolchains: [String]

@OptionGroup var root: GlobalOptions

Expand All @@ -54,87 +71,254 @@ struct Uninstall: SwiftlyCommand {
}

let startingConfig = try await Config.load(ctx)
let selectionResult = try await parseAndValidateToolchainSelectors(startingConfig)
let confirmedToolchains = try await handleErrorsAndGetConfirmation(ctx, selectionResult)

var toolchains: [ToolchainVersion]
if self.toolchain == "all" {
// Sort the uninstalled toolchains such that the in-use toolchain will be uninstalled last.
// This avoids printing any unnecessary output from using new toolchains while the uninstall is in progress.
toolchains = startingConfig.listInstalledToolchains(selector: nil).sorted { a, b in
a != startingConfig.inUse && (b == startingConfig.inUse || a < b)
}
} else {
let selector = try ToolchainSelector(parsing: self.toolchain)
var installedToolchains = startingConfig.listInstalledToolchains(selector: selector)
// This is in the unusual case that the inUse toolchain is not listed in the installed toolchains
if let inUse = startingConfig.inUse, selector.matches(toolchain: inUse) && !startingConfig.installedToolchains.contains(inUse) {
installedToolchains.append(inUse)
try await executeUninstalls(ctx, confirmedToolchains, startingConfig)
}

private func parseAndValidateToolchainSelectors(_ config: Config) async throws -> ToolchainSelectionResult {
var allToolchains: Set<ToolchainVersion> = Set()
var selectorToToolchains: [String: [ToolchainVersion]] = [:]
var invalidSelectors: [String] = []
var noMatchSelectors: [String] = []

for toolchainSelector in self.toolchains {
if toolchainSelector == UninstallConstants.allSelector {
let allInstalledToolchains = self.processAllSelector(config)
allToolchains.formUnion(allInstalledToolchains)
selectorToToolchains[toolchainSelector] = allInstalledToolchains
} else {
do {
let installedToolchains = try processIndividualSelector(toolchainSelector, config)

if installedToolchains.isEmpty {
noMatchSelectors.append(toolchainSelector)
} else {
allToolchains.formUnion(installedToolchains)
selectorToToolchains[toolchainSelector] = installedToolchains
}
} catch {
invalidSelectors.append(toolchainSelector)
}
}
toolchains = installedToolchains
}

// Filter out the xcode toolchain here since it is not uninstallable
toolchains.removeAll(where: { $0 == .xcodeVersion })
return ToolchainSelectionResult(
validToolchains: allToolchains,
selectorToToolchains: selectorToToolchains,
invalidSelectors: invalidSelectors,
noMatchSelectors: noMatchSelectors
)
}

private func processAllSelector(_ config: Config) -> [ToolchainVersion] {
config.listInstalledToolchains(selector: nil).sorted { a, b in
a != config.inUse && (b == config.inUse || a < b)
}
}

private func processIndividualSelector(_ selector: String, _ config: Config) throws -> [ToolchainVersion] {
let toolchainSelector = try ToolchainSelector(parsing: selector)
var installedToolchains = config.listInstalledToolchains(selector: toolchainSelector)

// This handles the unusual case that the inUse toolchain is not listed in the installed toolchains
if let inUse = config.inUse, toolchainSelector.matches(toolchain: inUse) && !config.installedToolchains.contains(inUse) {
installedToolchains.append(inUse)
}

return installedToolchains
}

private func handleErrorsAndGetConfirmation(
_ ctx: SwiftlyCoreContext,
_ selectionResult: ToolchainSelectionResult
) async throws -> [ToolchainVersion] {
if self.hasErrors(selectionResult) {
try await self.handleSelectionErrors(ctx, selectionResult)
}

let toolchains = self.prepareToolchainsForUninstall(selectionResult)

guard !toolchains.isEmpty else {
await ctx.message("No toolchains can be uninstalled that match \"\(self.toolchain)\"")
return
if self.toolchains.count == 1 {
await ctx.message("No toolchains can be uninstalled that match \"\(self.toolchains[0])\"")
} else {
await ctx.message("No toolchains can be uninstalled that match the provided selectors")
}
throw UninstallCancelledException()
}

if !self.root.assumeYes {
await ctx.message("The following toolchains will be uninstalled:")
try await self.confirmUninstallation(ctx, toolchains, selectionResult.selectorToToolchains)
}

for toolchain in toolchains {
await ctx.message(" \(toolchain)")
}
return toolchains
}

private func hasErrors(_ result: ToolchainSelectionResult) -> Bool {
!result.invalidSelectors.isEmpty || !result.noMatchSelectors.isEmpty
}

private func handleSelectionErrors(_ ctx: SwiftlyCoreContext, _ result: ToolchainSelectionResult) async throws {
var errorMessages: [String] = []

guard await ctx.promptForConfirmation(defaultBehavior: true) else {
if !result.invalidSelectors.isEmpty {
errorMessages.append("Invalid toolchain selectors: \(result.invalidSelectors.joined(separator: ", "))")
}

if !result.noMatchSelectors.isEmpty {
errorMessages.append("No toolchains match these selectors: \(result.noMatchSelectors.joined(separator: ", "))")
}

for message in errorMessages {
await ctx.message(message)
}

// If we have some valid selections, ask user if they want to proceed
if !result.validToolchains.isEmpty {
await ctx.message("\nFound \(result.validToolchains.count) toolchain(s) from valid selectors. Continue with uninstalling these?")
guard await ctx.promptForConfirmation(defaultBehavior: false) else {
await ctx.message("Aborting uninstall")
return
throw UninstallCancelledException()
}
} else {
// No valid toolchains found at all
await ctx.message("No valid toolchains found to uninstall.")
throw UninstallCancelledException()
}
}

private func prepareToolchainsForUninstall(_ selectionResult: ToolchainSelectionResult) -> [ToolchainVersion] {
// Convert Set back to Array - sorting will be done in execution phase with proper config access
var toolchains = Array(selectionResult.validToolchains)

// Filter out the xcode toolchain here since it is not uninstallable
toolchains.removeAll(where: { $0 == .xcodeVersion })

return toolchains
}

private func confirmUninstallation(
_ ctx: SwiftlyCoreContext,
_ toolchains: [ToolchainVersion],
_ _: [String: [ToolchainVersion]]
) async throws {
await self.displayToolchainConfirmation(ctx, toolchains)

guard await ctx.promptForConfirmation(defaultBehavior: true) else {
await ctx.message("Aborting uninstall")
throw UninstallCancelledException()
}
}

private func displayToolchainConfirmation(_ ctx: SwiftlyCoreContext, _ toolchains: [ToolchainVersion]) async {
await ctx.message("The following toolchains will be uninstalled:")
for toolchain in toolchains.sorted() {
await ctx.message(" \(toolchain)")
}
}

private func executeUninstalls(
_ ctx: SwiftlyCoreContext,
_ toolchains: [ToolchainVersion],
_ startingConfig: Config
) async throws {
await ctx.message()

for toolchain in toolchains {
// Apply proper sorting with access to config
let sortedToolchains = self.applySortingStrategy(toolchains, config: startingConfig)

for (index, toolchain) in sortedToolchains.enumerated() {
await self.displayProgress(ctx, index: index, total: sortedToolchains.count, toolchain: toolchain)

var config = try await Config.load(ctx)

// If the in-use toolchain was one of the uninstalled toolchains, use a new toolchain.
if toolchain == config.inUse {
let selector: ToolchainSelector
switch toolchain {
case let .stable(sr):
// If a.b.c was previously in use, switch to the latest a.b toolchain.
selector = .stable(major: sr.major, minor: sr.minor, patch: nil)
case let .snapshot(s):
// If a snapshot was previously in use, switch to the latest snapshot associated with that branch.
selector = .snapshot(branch: s.branch, date: nil)
case .xcode:
// Xcode will not be in the list of installed toolchains, so this is only here for completeness
selector = .xcode
}

if let toUse = config.listInstalledToolchains(selector: selector)
.filter({ !toolchains.contains($0) })
.max()
?? config.listInstalledToolchains(selector: .latest).filter({ !toolchains.contains($0) }).max()
?? config.installedToolchains.filter({ !toolchains.contains($0) }).max()
{
let pathChanged = try await Use.execute(ctx, toUse, globalDefault: true, verbose: self.root.verbose, &config)
if pathChanged {
try await Self.handlePathChange(ctx)
}
} else {
// If there are no more toolchains installed, just unuse the currently active toolchain.
config.inUse = nil
try config.save(ctx)
}
try await self.handleInUseToolchainReplacement(ctx, toolchain, sortedToolchains, &config)
}

try await Self.execute(ctx, toolchain, &config, verbose: self.root.verbose)
}

await self.displayCompletionMessage(ctx, sortedToolchains.count)
}

private func applySortingStrategy(_ toolchains: [ToolchainVersion], config: Config) -> [ToolchainVersion] {
toolchains.sorted { a, b in
a != config.inUse && (b == config.inUse || a < b)
}
}

private func handleInUseToolchainReplacement(
_ ctx: SwiftlyCoreContext,
_ toolchain: ToolchainVersion,
_ allUninstallTargets: [ToolchainVersion],
_ config: inout Config
) async throws {
let replacementSelector = self.createReplacementSelector(for: toolchain)

if let replacement = self.findSuitableReplacement(config, replacementSelector, excluding: allUninstallTargets) {
let pathChanged = try await Use.execute(ctx, replacement, globalDefault: true, verbose: self.root.verbose, &config)
if pathChanged {
try await Self.handlePathChange(ctx)
}
} else {
config.inUse = nil
try config.save(ctx)
}
}

private func createReplacementSelector(for toolchain: ToolchainVersion) -> ToolchainSelector {
switch toolchain {
case let .stable(sr):
// If a.b.c was previously in use, switch to the latest a.b toolchain.
return .stable(major: sr.major, minor: sr.minor, patch: nil)
case let .snapshot(s):
// If a snapshot was previously in use, switch to the latest snapshot associated with that branch.
return .snapshot(branch: s.branch, date: nil)
case .xcode:
// Xcode will not be in the list of installed toolchains, so this is only here for completeness
return .xcode
}
}

private func findSuitableReplacement(
_ config: Config,
_ selector: ToolchainSelector,
excluding: [ToolchainVersion]
) -> ToolchainVersion? {
// Try the specific selector first
if let replacement = config.listInstalledToolchains(selector: selector)
.filter({ !excluding.contains($0) })
.max()
{
return replacement
}

// Try latest stable as fallback, but only if there are stable toolchains
let stableToolchains = config.installedToolchains.filter { $0.isStableRelease() && !excluding.contains($0) }
if !stableToolchains.isEmpty {
return stableToolchains.max()
}

// Finally, try any remaining toolchain
return config.installedToolchains.filter { !excluding.contains($0) }.max()
}

private func displayProgress(_ ctx: SwiftlyCoreContext, index: Int, total: Int, toolchain: ToolchainVersion) async {
if total > 1 {
await ctx.message("[\(index + 1)/\(total)] Processing \(toolchain)")
}
}

private func displayCompletionMessage(_ ctx: SwiftlyCoreContext, _ toolchainCount: Int) async {
await ctx.message()
await ctx.message("\(toolchains.count) toolchain(s) successfully uninstalled")
if self.toolchains.count == 1 {
await ctx.message("\(toolchainCount) toolchain(s) successfully uninstalled")
} else {
await ctx.message("Successfully uninstalled \(toolchainCount) toolchain(s) from \(self.toolchains.count) selector(s)")
}
}

static func execute(
Expand Down
Loading