Skip to content

Create a special Xcode selector for macOS #317

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jul 30, 2025
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
4 changes: 4 additions & 0 deletions Documentation/SwiftlyDocs.docc/swiftly-cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@ Likewise, the latest snapshot associated with a given development branch can be
$ swiftly use 5.7-snapshot
$ swiftly use main-snapshot

macOS ONLY: There is a special selector for swiftly to use your Xcode toolchain. If there are multiple versions of Xcode then swiftly will use the currently selected toolchain from xcode-select.

$ swiftly use xcode


**--version:**

Expand Down
11 changes: 9 additions & 2 deletions Sources/MacOSPlatform/MacOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,16 @@ public struct MacOS: Platform {
return "/bin/zsh"
}

public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> FilePath
public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> FilePath
{
self.swiftlyToolchainsDir(ctx) / "\(toolchain.identifier).xctoolchain"
if toolchain == .xcodeVersion {
// Print the toolchain location with the help of xcrun
if let xcrunLocation = try? await self.runProgramOutput("/usr/bin/xcrun", "-f", "swift") {
return FilePath(xcrunLocation.replacingOccurrences(of: "\n", with: "")).removingLastComponent().removingLastComponent().removingLastComponent()
}
}

return self.swiftlyToolchainsDir(ctx) / "\(toolchain.identifier).xctoolchain"
}

public static let currentPlatform: any Platform = MacOS()
Expand Down
10 changes: 8 additions & 2 deletions Sources/Swiftly/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,14 @@ public struct Config: Codable, Equatable, Sendable {
}

public func listInstalledToolchains(selector: ToolchainSelector?) -> [ToolchainVersion] {
#if os(macOS)
let systemToolchains: [ToolchainVersion] = [.xcodeVersion]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is Xcode guaranteed to be installed where swiftly is installed? Should we query to ensure that there is a toolchain installed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If Xcode isn't installed then this will end up prompting the user on the console to install the CLT, and then that could be the system Xcode toolchain that it uses. Otherwise, if the user installs an Xcode, and either sets it with xcode-select or sets the DEVELOPER_DIR then swiftly will start proxying to that.

#else
let systemToolchains: [ToolchainVersion] = []
#endif

guard let selector else {
return Array(self.installedToolchains)
return Array(self.installedToolchains) + systemToolchains
}

if case .latest = selector {
Expand All @@ -61,7 +67,7 @@ public struct Config: Codable, Equatable, Sendable {
return ts
}

return self.installedToolchains.filter { toolchain in
return (self.installedToolchains + systemToolchains).filter { toolchain in
selector.matches(toolchain: toolchain)
}
}
Expand Down
14 changes: 2 additions & 12 deletions Sources/Swiftly/Init.swift
Original file line number Diff line number Diff line change
Expand Up @@ -281,18 +281,8 @@ struct Init: SwiftlyCommand {
""")
}

// Fish doesn't have path caching, so this might only be needed for bash/zsh
if pathChanged && !quietShellFollowup && !shell.hasSuffix("fish") {
await ctx.message("""
Your shell caches items on your path for better performance. Swiftly has added
items to your path that may not get picked up right away. You can update your
shell's environment by running

hash -r

or restarting your shell.

""")
if pathChanged && !quietShellFollowup {
try await Self.handlePathChange(ctx)
}

if let postInstall {
Expand Down
33 changes: 10 additions & 23 deletions Sources/Swiftly/Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,26 +121,8 @@ struct Install: SwiftlyCommand {
progressFile: self.progressFile
)

let shell =
if let s = ProcessInfo.processInfo.environment["SHELL"]
{
s
} else {
try await Swiftly.currentPlatform.getShell()
}

// Fish doesn't cache its path, so this instruction is not necessary.
if pathChanged && !shell.hasSuffix("fish") {
await ctx.message(
"""
NOTE: Swiftly has updated some elements in your path and your shell may not yet be
aware of the changes. You can update your shell's environment by running

hash -r

or restarting your shell.

""")
if pathChanged {
try await Self.handlePathChange(ctx)
}

if let postInstallScript {
Expand Down Expand Up @@ -176,7 +158,7 @@ struct Install: SwiftlyCommand {
let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(ctx)
let swiftlyBinDirContents =
(try? await fs.ls(atPath: swiftlyBinDir)) ?? [String]()
let toolchainBinDir = Swiftly.currentPlatform.findToolchainBinDir(ctx, version)
let toolchainBinDir = try await Swiftly.currentPlatform.findToolchainBinDir(ctx, version)
let toolchainBinDirContents = try await fs.ls(atPath: toolchainBinDir)

var existingProxies: [String] = []
Expand Down Expand Up @@ -316,6 +298,8 @@ struct Install: SwiftlyCommand {
case .main:
category = "development"
}
case .xcode:
fatalError("unreachable: xcode toolchain cannot be installed with swiftly")
}

let animation: ProgressReporterProtocol? =
Expand Down Expand Up @@ -408,7 +392,7 @@ struct Install: SwiftlyCommand {
verbose: verbose
)

let pathChanged = try await Self.setupProxies(
var pathChanged = try await Self.setupProxies(
ctx,
version: version,
verbose: verbose,
Expand All @@ -422,7 +406,8 @@ struct Install: SwiftlyCommand {
// If this is the first installed toolchain, mark it as in-use regardless of whether the
// --use argument was provided.
if useInstalledToolchain {
try await Use.execute(ctx, version, globalDefault: false, &config)
let pc = try await Use.execute(ctx, version, globalDefault: false, verbose: verbose, &config)
pathChanged = pathChanged || pc
}

// We always update the global default toolchain if there is none set. This could
Expand Down Expand Up @@ -523,6 +508,8 @@ struct Install: SwiftlyCommand {
}

return .snapshot(firstSnapshot)
case .xcode:
throw SwiftlyError(message: "xcode toolchains are not available from swift.org")
}
}
}
2 changes: 1 addition & 1 deletion Sources/Swiftly/List.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ struct List: SwiftlyCommand {
let toolchains = config.listInstalledToolchains(selector: selector).sorted { $0 > $1 }
let (inUse, _) = try await selectToolchain(ctx, config: &config)

let installedToolchainInfos = toolchains.compactMap { toolchain -> InstallToolchainInfo? in
var installedToolchainInfos = toolchains.compactMap { toolchain -> InstallToolchainInfo? in
InstallToolchainInfo(
version: toolchain,
inUse: inUse == toolchain,
Expand Down
16 changes: 16 additions & 0 deletions Sources/Swiftly/OutputSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ struct AvailableToolchainInfo: OutputData {
try versionContainer.encode(major, forKey: .major)
try versionContainer.encode(minor, forKey: .minor)
}
case .xcode:
try versionContainer.encode("system", forKey: .type)
}
}
}
Expand Down Expand Up @@ -233,6 +235,8 @@ struct InstallToolchainInfo: OutputData {
try versionContainer.encode(major, forKey: .major)
try versionContainer.encode(minor, forKey: .minor)
}
case .xcode:
try versionContainer.encode("system", forKey: .type)
}
}

Expand Down Expand Up @@ -279,6 +283,9 @@ struct InstallToolchainInfo: OutputData {
branch: branch,
date: date
))
case "system":
// The only system toolchain that exists at the moment is the xcode version
self.version = .xcode
default:
throw DecodingError.dataCorruptedError(
forKey: ToolchainVersionCodingKeys.type,
Expand Down Expand Up @@ -314,6 +321,8 @@ struct InstalledToolchainsListInfo: OutputData {
"main development snapshot"
case let .snapshot(.release(major, minor), nil):
"\(major).\(minor) development snapshot"
case .xcode:
"xcode"
default:
"matching"
}
Expand All @@ -334,6 +343,13 @@ struct InstalledToolchainsListInfo: OutputData {
lines.append("Installed snapshot toolchains")
lines.append("-----------------------------")
lines.append(contentsOf: snapshotToolchains.map(\.description))

#if os(macOS)
lines.append("")
lines.append("Available system toolchains")
lines.append("---------------------------")
lines.append(ToolchainVersion.xcode.description)
#endif
}

return lines.joined(separator: "\n")
Expand Down
24 changes: 24 additions & 0 deletions Sources/Swiftly/Swiftly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,28 @@ extension SwiftlyCommand {
}
}
}

public static func handlePathChange(_ ctx: SwiftlyCoreContext) async throws {
let shell =
if let s = ProcessInfo.processInfo.environment["SHELL"]
{
s
} else {
try await Swiftly.currentPlatform.getShell()
}

// Fish doesn't cache its path, so this instruction is not necessary.
if !shell.hasSuffix("fish") {
await ctx.message(
"""
NOTE: Swiftly has updated some elements in your path and your shell may not yet be
aware of the changes. You can update your shell's environment by running

hash -r

or restarting your shell.

""")
}
}
}
15 changes: 12 additions & 3 deletions Sources/Swiftly/Uninstall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ struct Uninstall: SwiftlyCommand {

let startingConfig = try await Config.load(ctx)

let toolchains: [ToolchainVersion]
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.
Expand All @@ -72,8 +72,11 @@ struct Uninstall: SwiftlyCommand {
toolchains = installedToolchains
}

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

guard !toolchains.isEmpty else {
await ctx.message("No toolchains matched \"\(self.toolchain)\"")
await ctx.message("No toolchains can be uninstalled that match \"\(self.toolchain)\"")
return
}

Expand Down Expand Up @@ -105,6 +108,9 @@ struct Uninstall: SwiftlyCommand {
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)
Expand All @@ -113,7 +119,10 @@ struct Uninstall: SwiftlyCommand {
?? config.listInstalledToolchains(selector: .latest).filter({ !toolchains.contains($0) }).max()
?? config.installedToolchains.filter({ !toolchains.contains($0) }).max()
{
try await Use.execute(ctx, toUse, globalDefault: true, &config)
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
Expand Down
2 changes: 2 additions & 0 deletions Sources/Swiftly/Update.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ struct Update: SwiftlyCommand {
default:
fatalError("unreachable")
}
case let .xcode:
throw SwiftlyError(message: "xcode cannot be updated from swiftly")
}
}

Expand Down
28 changes: 23 additions & 5 deletions Sources/Swiftly/Use.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ struct Use: SwiftlyCommand {

$ swiftly use 5.7-snapshot
$ swiftly use main-snapshot

macOS ONLY: There is a special selector for swiftly to use your Xcode toolchain. \
If there are multiple versions of Xcode then swiftly will use the currently selected \
toolchain from xcode-select.

$ swiftly use xcode
"""
))
var toolchain: String?
Expand Down Expand Up @@ -87,7 +93,7 @@ struct Use: SwiftlyCommand {
}

if self.printLocation {
let location = LocationInfo(path: "\(Swiftly.currentPlatform.findToolchainLocation(ctx, selectedVersion))")
let location = LocationInfo(path: "\(try await Swiftly.currentPlatform.findToolchainLocation(ctx, selectedVersion))")
try await ctx.output(location)
return
}
Expand Down Expand Up @@ -115,11 +121,14 @@ struct Use: SwiftlyCommand {
throw SwiftlyError(message: "No installed toolchains match \"\(toolchain)\"")
}

try await Self.execute(ctx, toolchain, globalDefault: self.globalDefault, assumeYes: self.root.assumeYes, &config)
let pathChanged = try await Self.execute(ctx, toolchain, globalDefault: self.globalDefault, verbose: self.root.verbose, assumeYes: self.root.assumeYes, &config)
if pathChanged {
try await Self.handlePathChange(ctx)
}
}

/// Use a toolchain. This method can modify and save the input config and also create/modify a `.swift-version` file.
static func execute(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, globalDefault: Bool, assumeYes: Bool = true, _ config: inout Config) async throws {
static func execute(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, globalDefault: Bool, verbose: Bool, assumeYes: Bool = true, _ config: inout Config) async throws -> Bool {
let (selectedVersion, result) = try await selectToolchain(ctx, config: &config, globalDefault: globalDefault)

let isGlobal: Bool
Expand All @@ -136,7 +145,7 @@ struct Use: SwiftlyCommand {

guard await ctx.promptForConfirmation(defaultBehavior: true) else {
await ctx.message("Aborting setting in-use toolchain")
return
return false
}
}

Expand All @@ -150,12 +159,21 @@ struct Use: SwiftlyCommand {
configFile = nil
}

let pathChanged = try await Install.setupProxies(
ctx,
version: toolchain,
verbose: verbose,
assumeYes: assumeYes
)

try await ctx.output(ToolchainSetInfo(
version: toolchain,
previousVersion: selectedVersion,
isGlobal: isGlobal,
versionFile: configFile
))

return pathChanged
}

static func findNewVersionFile(_ ctx: SwiftlyCoreContext) async throws -> FilePath? {
Expand Down Expand Up @@ -251,7 +269,7 @@ public func selectToolchain(_ ctx: SwiftlyCoreContext, config: inout Config, glo

// Check to ensure that the global default in use toolchain matches one of the installed toolchains, and return
// no selected toolchain if it doesn't.
guard let defaultInUse = config.inUse, config.installedToolchains.contains(defaultInUse) else {
guard let defaultInUse = config.inUse, config.listInstalledToolchains(selector: nil).contains(defaultInUse) else {
return (nil, .globalDefault)
}

Expand Down
Loading