Skip to content

Create a special xcode selector that will delegates to xcrun 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

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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 @@ -206,6 +206,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
12 changes: 9 additions & 3 deletions Sources/MacOSPlatform/MacOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,15 @@ public struct MacOS: Platform {
return "/bin/zsh"
}

public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL
{
self.swiftlyToolchainsDir(ctx).appendingPathComponent("\(toolchain.identifier).xctoolchain")
public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> URL {
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 URL(filePath: xcrunLocation.replacingOccurrences(of: "\n", with: "")).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
}
}

return self.swiftlyToolchainsDir(ctx).appendingPathComponent("\(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 @@ -51,8 +51,14 @@ public struct Config: Codable, Equatable {
}

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

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

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

return self.installedToolchains.filter { toolchain in
return (self.installedToolchains + systemToolchains).filter { toolchain in
selector.matches(toolchain: toolchain)
}
}
Expand Down
6 changes: 5 additions & 1 deletion Sources/Swiftly/Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ struct Install: SwiftlyCommand {
case .main:
category = "development"
}
case .xcode:
fatalError("unreachable: xcode toolchain cannot be installed with swiftly")
}

let animation = PercentProgressAnimation(
Expand Down Expand Up @@ -282,7 +284,7 @@ struct Install: SwiftlyCommand {
let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(ctx)
let swiftlyBinDirContents =
(try? FileManager.default.contentsOfDirectory(atPath: swiftlyBinDir.path)) ?? [String]()
let toolchainBinDir = Swiftly.currentPlatform.findToolchainBinDir(ctx, version)
let toolchainBinDir = try await Swiftly.currentPlatform.findToolchainBinDir(ctx, version)
let toolchainBinDirContents = try FileManager.default.contentsOfDirectory(
atPath: toolchainBinDir.path)

Expand Down Expand Up @@ -429,6 +431,8 @@ struct Install: SwiftlyCommand {
}

return .snapshot(firstSnapshot)
case .xcode:
throw SwiftlyError(message: "xcode toolchains are not available from swift.org")
}
}
}
5 changes: 5 additions & 0 deletions Sources/Swiftly/List.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ struct List: SwiftlyCommand {
for toolchain in toolchains where toolchain.isSnapshot() {
await printToolchain(toolchain)
}

#if os(macOS)
await ctx.print("")
await printToolchain(ToolchainVersion.xcode)
#endif
}
}
}
10 changes: 8 additions & 2 deletions Sources/Swiftly/Uninstall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ struct Uninstall: SwiftlyCommand {
try validateSwiftly(ctx)
let startingConfig = try 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 @@ -68,8 +68,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.print("No toolchains matched \"\(self.toolchain)\"")
await ctx.print("No toolchains can be uninstalled that match \"\(self.toolchain)\"")
return
}

Expand Down Expand Up @@ -101,6 +104,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 Down
2 changes: 2 additions & 0 deletions Sources/Swiftly/Update.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ struct Update: SwiftlyCommand {
default:
fatalError("unreachable")
}
case let .xcode:
throw SwiftlyError(message: "xcode cannot be updated from swiftly")
}
}

Expand Down
10 changes: 8 additions & 2 deletions Sources/Swiftly/Use.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,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 @@ -78,7 +84,7 @@ struct Use: SwiftlyCommand {

if self.printLocation {
// Print the toolchain location and exit
await ctx.print("\(Swiftly.currentPlatform.findToolchainLocation(ctx, selectedVersion).path)")
await ctx.print("\(try await Swiftly.currentPlatform.findToolchainLocation(ctx, selectedVersion).path)")
return
}

Expand Down Expand Up @@ -240,7 +246,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
14 changes: 7 additions & 7 deletions Sources/SwiftlyCore/Platform.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,10 @@ public protocol Platform: Sendable {
func getShell() async throws -> String

/// Find the location where the toolchain should be installed.
func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL
func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> URL

/// Find the location of the toolchain binaries.
func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL
func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> URL
}

extension Platform {
Expand Down Expand Up @@ -164,11 +164,11 @@ extension Platform {
}

#if os(macOS) || os(Linux)
func proxyEnv(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) throws -> [
func proxyEnv(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> [
String:
String
] {
let tcPath = self.findToolchainLocation(ctx, toolchain).appendingPathComponent("usr/bin")
let tcPath = try await self.findToolchainLocation(ctx, toolchain).appendingPathComponent("usr/bin")
guard tcPath.fileExists() else {
throw SwiftlyError(
message:
Expand Down Expand Up @@ -196,7 +196,7 @@ extension Platform {
_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, _ command: String,
_ arguments: [String], _ env: [String: String] = [:]
) async throws {
var newEnv = try self.proxyEnv(ctx, toolchain)
var newEnv = try await self.proxyEnv(ctx, toolchain)
for (key, value) in env {
newEnv[key] = value
}
Expand Down Expand Up @@ -436,9 +436,9 @@ extension Platform {
return FileManager.default.fileExists(atPath: swiftlyHomeBin) ? swiftlyHomeBin : nil
}

public func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL
public func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> URL
{
self.findToolchainLocation(ctx, toolchain).appendingPathComponent("usr/bin")
try await self.findToolchainLocation(ctx, toolchain).appendingPathComponent("usr/bin")
}

#endif
Expand Down
34 changes: 32 additions & 2 deletions Sources/SwiftlyCore/ToolchainVersion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ public enum ToolchainVersion: Sendable {

case stable(StableRelease)
case snapshot(Snapshot)
case xcode

public init(major: Int, minor: Int, patch: Int) {
self = .stable(StableRelease(major: major, minor: minor, patch: patch))
Expand All @@ -99,6 +100,8 @@ public enum ToolchainVersion: Sendable {
self = .snapshot(Snapshot(branch: snapshotBranch, date: date))
}

public static let xcodeVersion: ToolchainVersion = .xcode

static func stableRegex() -> Regex<(Substring, Substring, Substring, Substring)> {
try! Regex("^(?:Swift )?(\\d+)\\.(\\d+)\\.(\\d+)$")
}
Expand Down Expand Up @@ -132,6 +135,8 @@ public enum ToolchainVersion: Sendable {
throw SwiftlyError(message: "invalid release snapshot version: \(string)")
}
self = ToolchainVersion(snapshotBranch: .release(major: major, minor: minor), date: String(match.output.3))
} else if string == "xcode" {
self = ToolchainVersion.xcodeVersion
} else {
throw SwiftlyError(message: "invalid toolchain version: \"\(string)\"")
}
Expand Down Expand Up @@ -176,6 +181,8 @@ public enum ToolchainVersion: Sendable {
case let .release(major, minor):
return "\(major).\(minor)-snapshot-\(release.date)"
}
case .xcode:
return "xcode"
}
}

Expand All @@ -194,6 +201,8 @@ public enum ToolchainVersion: Sendable {
case let .release(major, minor):
return "swift-\(major).\(minor)-DEVELOPMENT-SNAPSHOT-\(release.date)-a"
}
case .xcode:
return "xcode"
}
}
}
Expand All @@ -214,6 +223,8 @@ extension ToolchainVersion: CustomStringConvertible {
return "\(release)"
case let .snapshot(snapshot):
return "\(snapshot)"
case .xcode:
return "xcode"
}
}
}
Expand All @@ -231,6 +242,14 @@ extension ToolchainVersion: Comparable {
return false
case (.stable, .snapshot):
return !(rhs < lhs)
case (.xcode, .xcode):
return false
case (.xcode, _):
return false
case (_, .xcode):
return true
default:
return false
}
}
}
Expand All @@ -254,6 +273,9 @@ public enum ToolchainSelector: Sendable {
/// associated with the given branch.
case snapshot(branch: ToolchainVersion.Snapshot.Branch, date: String?)

/// Selects the Xcode of the current system.
case xcode

public init(major: Int, minor: Int? = nil, patch: Int? = nil) {
self = .stable(major: major, minor: minor, patch: patch)
}
Expand All @@ -267,14 +289,19 @@ public enum ToolchainSelector: Sendable {
return
}

if input == "xcode" {
self = Self.xcode
return
}

throw SwiftlyError(message: "invalid toolchain selector: \"\(input)\"")
}

public func isReleaseSelector() -> Bool {
switch self {
case .latest, .stable:
return true
case .snapshot:
default:
return false
}
}
Expand Down Expand Up @@ -312,7 +339,8 @@ public enum ToolchainSelector: Sendable {
}
}
return true

case (.xcode, .xcode):
return true
default:
return false
}
Expand Down Expand Up @@ -341,6 +369,8 @@ extension ToolchainSelector: CustomStringConvertible {
s += "-\(date)"
}
return s
case .xcode:
return "xcode"
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions Tests/SwiftlyTests/InstallTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -262,4 +262,11 @@ import Testing
try await SwiftlyTests.installMockedToolchain(selector: ToolchainVersion.newStable.name, args: ["--use"])
try await SwiftlyTests.validateInUse(expected: .newStable)
}

/// Verify that xcode can't be installed like regular toolchains
@Test(.testHomeMockedToolchain()) func installXcode() async throws {
try await #expect(throws: SwiftlyError.self) {
try await SwiftlyTests.runCommand(Install.self, ["install", "xcode", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"])
}
}
}
20 changes: 18 additions & 2 deletions Tests/SwiftlyTests/ListTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,19 @@ import Testing
let output = try await SwiftlyTests.runWithMockedIO(List.self, args)

let parsedToolchains = output.compactMap { outputLine in
#if !os(macOS)
Set<ToolchainVersion>.allToolchains().first {
outputLine.contains(String(describing: $0))
}
#else
(Set<ToolchainVersion>.allToolchains() + [.xcodeVersion]).first {
outputLine.contains(String(describing: $0))
}
#endif
}

// Ensure extra toolchains weren't accidentally included in the output.
guard parsedToolchains.count == output.filter({ $0.hasPrefix("Swift") || $0.contains("-snapshot") }).count else {
guard parsedToolchains.count == output.filter({ $0.hasPrefix("Swift") || $0.contains("-snapshot") || $0.contains("xcode") }).count else {
throw SwiftlyTestError(message: "unexpected listed toolchains in \(output)")
}

Expand All @@ -62,7 +68,11 @@ import Testing
@Test func list() async throws {
try await self.runListTest {
let toolchains = try await self.runList(selector: nil)
#if !os(macOS)
#expect(toolchains == Self.sortedReleaseToolchains + Self.sortedSnapshotToolchains)
#else
#expect(toolchains == Self.sortedReleaseToolchains + Self.sortedSnapshotToolchains + [.xcodeVersion])
#endif
}
}

Expand Down Expand Up @@ -155,8 +165,14 @@ import Testing

/// Tests that `list` properly handles the case where no toolchains have been installed yet.
@Test(.testHome(Self.homeName)) func listEmpty() async throws {
#if !os(macOS)
let systemToolchains: [ToolchainVersion] = []
#else
let systemToolchains: [ToolchainVersion] = [.xcodeVersion]
#endif

var toolchains = try await self.runList(selector: nil)
#expect(toolchains == [])
#expect(toolchains == systemToolchains)

toolchains = try await self.runList(selector: "5")
#expect(toolchains == [])
Expand Down
Loading