Skip to content

Add Swiftly toolchain management #1717

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

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
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
16 changes: 15 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1829,6 +1829,7 @@
"lcov-parse": "^1.0.0",
"plist": "^3.1.0",
"vscode-languageclient": "^9.0.1",
"xml2js": "^0.6.2"
"xml2js": "^0.6.2",
"zod": "^4.0.5"
}
}
175 changes: 175 additions & 0 deletions src/toolchain/swiftly.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the VS Code Swift open source project
//
// Copyright (c) 2025 the VS Code Swift project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of VS Code Swift project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import * as path from "node:path";
import { SwiftlyConfig } from "./ToolchainVersion";
import * as fs from "node:fs/promises";
import { execFile, ExecFileError } from "../utilities/utilities";
import * as vscode from "vscode";
import { Version } from "../utilities/version";
import { z } from "zod";

const ListAvailableResult = z.object({
toolchains: z.array(
z.object({
inUse: z.boolean(),
installed: z.boolean(),
isDefault: z.boolean(),
name: z.string(),
version: z.discriminatedUnion("type", [
z.object({
major: z.number(),
minor: z.number(),
patch: z.number().optional(),
type: z.literal("stable"),
}),
z.object({
major: z.number(),
minor: z.number(),
branch: z.string(),
date: z.string(),

type: z.literal("snapshot"),
}),
]),
})
),
});

export class Swiftly {
/**
* Finds the version of Swiftly installed on the system.
*
* @returns the version of Swiftly as a `Version` object, or `undefined`
* if Swiftly is not installed or not supported.
*/
public static async version(): Promise<Version | undefined> {
if (!Swiftly.isSupported()) {
return undefined;
}
const { stdout } = await execFile("swiftly", ["--version"]);
return Version.fromString(stdout.trim());
}

/**
* Finds the list of toolchains managed by Swiftly.
*
* @returns an array of toolchain paths
*/
public static async listAvailableToolchains(): Promise<string[]> {
if (!this.isSupported()) {
return [];
}
const version = await Swiftly.version();
if (version?.isLessThan(new Version(1, 1, 0))) {
return await Swiftly.getToolchainInstallLegacy();
}

return await Swiftly.getListAvailableToolchains();
}

private static async getListAvailableToolchains(): Promise<string[]> {
try {
const { stdout } = await execFile("swiftly", ["list-available", "--format=json"]);
const response = ListAvailableResult.parse(JSON.parse(stdout));
return response.toolchains.map(t => t.name);
} catch (error) {
throw new Error(
`Failed to retrieve Swiftly installations from disk: ${(error as Error).message}`
);
}
}

private static async getToolchainInstallLegacy() {
try {
const swiftlyHomeDir: string | undefined = process.env["SWIFTLY_HOME_DIR"];
if (!swiftlyHomeDir) {
return [];
}
const swiftlyConfig = await Swiftly.getConfig();
if (!swiftlyConfig || !("installedToolchains" in swiftlyConfig)) {
return [];
}
const installedToolchains = swiftlyConfig.installedToolchains;
if (!Array.isArray(installedToolchains)) {
return [];
}
return installedToolchains
.filter((toolchain): toolchain is string => typeof toolchain === "string")
.map(toolchain => path.join(swiftlyHomeDir, "toolchains", toolchain));
} catch (error) {
throw new Error(
`Failed to retrieve Swiftly installations from disk: ${(error as Error).message}`
);
}
}

private static isSupported() {
return process.platform === "linux" || process.platform === "darwin";
}

public static async inUseLocation(swiftlyPath: string, cwd?: vscode.Uri) {
const { stdout: inUse } = await execFile(swiftlyPath, ["use", "--print-location"], {
cwd: cwd?.fsPath,
});
return inUse.trimEnd();
}

/**
* Determine if Swiftly is being used to manage the active toolchain and if so, return
* the path to the active toolchain.
* @returns The location of the active toolchain if swiftly is being used to manage it.
*/
public static async toolchain(cwd?: vscode.Uri): Promise<string | undefined> {
const swiftlyHomeDir: string | undefined = process.env["SWIFTLY_HOME_DIR"];
if (swiftlyHomeDir) {
const { stdout: swiftLocation } = await execFile("which", ["swift"]);
if (swiftLocation.startsWith(swiftlyHomeDir)) {
// Print the location of the toolchain that swiftly is using. If there
// is no cwd specified then it returns the global "inUse" toolchain otherwise
// it respects the .swift-version file in the cwd and resolves using that.
try {
const inUse = await Swiftly.inUseLocation("swiftly", cwd);
if (inUse.length > 0) {
return path.join(inUse, "usr");
}
} catch (err: unknown) {
const error = err as ExecFileError;
// Its possible the toolchain in .swift-version is misconfigured or doesn't exist.
void vscode.window.showErrorMessage(
`Failed to load toolchain from Swiftly: ${error.stderr}`
);
}
}
}
return undefined;
}

/**
* Reads the Swiftly configuration file, if it exists.
*
* @returns A parsed Swiftly configuration.
*/
private static async getConfig(): Promise<SwiftlyConfig | undefined> {
const swiftlyHomeDir: string | undefined = process.env["SWIFTLY_HOME_DIR"];
if (!swiftlyHomeDir) {
return;
}
const swiftlyConfigRaw = await fs.readFile(
path.join(swiftlyHomeDir, "config.json"),
"utf-8"
);
return JSON.parse(swiftlyConfigRaw);
}
}
94 changes: 5 additions & 89 deletions src/toolchain/toolchain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,13 @@ import * as plist from "plist";
import * as vscode from "vscode";
import configuration from "../configuration";
import { SwiftOutputChannel } from "../ui/SwiftOutputChannel";
import { execFile, ExecFileError, execSwift } from "../utilities/utilities";
import { execFile, execSwift } from "../utilities/utilities";
import { expandFilePathTilde, fileExists, pathExists } from "../utilities/filesystem";
import { Version } from "../utilities/version";
import { BuildFlags } from "./BuildFlags";
import { Sanitizer } from "./Sanitizer";
import { SwiftlyConfig } from "./ToolchainVersion";
import { lineBreakRegex } from "../utilities/tasks";

import { Swiftly } from "./swiftly";
/**
* Contents of **Info.plist** on Windows.
*/
Expand Down Expand Up @@ -251,54 +250,6 @@ export class SwiftToolchain {
return result;
}

/**
* Finds the list of toolchains managed by Swiftly.
*
* @returns an array of toolchain paths
*/
public static async getSwiftlyToolchainInstalls(): Promise<string[]> {
// Swiftly is available on Linux and macOS
if (process.platform !== "linux" && process.platform !== "darwin") {
return [];
}
try {
const swiftlyHomeDir: string | undefined = process.env["SWIFTLY_HOME_DIR"];
if (!swiftlyHomeDir) {
return [];
}
const swiftlyConfig = await SwiftToolchain.getSwiftlyConfig();
if (!swiftlyConfig || !("installedToolchains" in swiftlyConfig)) {
return [];
}
const installedToolchains = swiftlyConfig.installedToolchains;
if (!Array.isArray(installedToolchains)) {
return [];
}
return installedToolchains
.filter((toolchain): toolchain is string => typeof toolchain === "string")
.map(toolchain => path.join(swiftlyHomeDir, "toolchains", toolchain));
} catch (error) {
throw new Error("Failed to retrieve Swiftly installations from disk.");
}
}

/**
* Reads the Swiftly configuration file, if it exists.
*
* @returns A parsed Swiftly configuration.
*/
private static async getSwiftlyConfig(): Promise<SwiftlyConfig | undefined> {
const swiftlyHomeDir: string | undefined = process.env["SWIFTLY_HOME_DIR"];
if (!swiftlyHomeDir) {
return;
}
const swiftlyConfigRaw = await fs.readFile(
path.join(swiftlyHomeDir, "config.json"),
"utf-8"
);
return JSON.parse(swiftlyConfigRaw);
}

/**
* Checks common directories for available swift toolchain installations.
*
Expand Down Expand Up @@ -615,7 +566,7 @@ export class SwiftToolchain {
let realSwift = await fs.realpath(swift);
if (path.basename(realSwift) === "swiftly") {
try {
const inUse = await this.swiftlyInUseLocation(realSwift, cwd);
const inUse = await Swiftly.inUseLocation(realSwift, cwd);
if (inUse) {
realSwift = path.join(inUse, "usr", "bin", "swift");
}
Expand Down Expand Up @@ -668,7 +619,7 @@ export class SwiftToolchain {
const swiftlyPath = path.join(configPath, "swiftly");
if (await fileExists(swiftlyPath)) {
try {
const inUse = await this.swiftlyInUseLocation(swiftlyPath, cwd);
const inUse = await Swiftly.inUseLocation(swiftlyPath, cwd);
if (inUse) {
return path.join(inUse, "usr");
}
Expand All @@ -679,7 +630,7 @@ export class SwiftToolchain {
return path.dirname(configuration.path);
}

const swiftlyToolchainLocation = await this.swiftlyToolchain(cwd);
const swiftlyToolchainLocation = await Swiftly.toolchain(cwd);
if (swiftlyToolchainLocation) {
return swiftlyToolchainLocation;
}
Expand All @@ -699,41 +650,6 @@ export class SwiftToolchain {
}
}

private static async swiftlyInUseLocation(swiftlyPath: string, cwd?: vscode.Uri) {
const { stdout: inUse } = await execFile(swiftlyPath, ["use", "--print-location"], {
cwd: cwd?.fsPath,
});
return inUse.trimEnd();
}

/**
* Determine if Swiftly is being used to manage the active toolchain and if so, return
* the path to the active toolchain.
* @returns The location of the active toolchain if swiftly is being used to manage it.
*/
private static async swiftlyToolchain(cwd?: vscode.Uri): Promise<string | undefined> {
const swiftlyHomeDir: string | undefined = process.env["SWIFTLY_HOME_DIR"];
if (swiftlyHomeDir) {
const { stdout: swiftLocation } = await execFile("which", ["swift"]);
if (swiftLocation.indexOf(swiftlyHomeDir) === 0) {
// Print the location of the toolchain that swiftly is using. If there
// is no cwd specified then it returns the global "inUse" toolchain otherwise
// it respects the .swift-version file in the cwd and resolves using that.
try {
const inUse = await this.swiftlyInUseLocation("swiftly", cwd);
if (inUse.length > 0) {
return path.join(inUse, "usr");
}
} catch (err: unknown) {
const error = err as ExecFileError;
// Its possible the toolchain in .swift-version is misconfigured or doesn't exist.
void vscode.window.showErrorMessage(`${error.stderr}`);
}
}
}
return undefined;
}

/**
* @param targetInfo swift target info
* @returns path to Swift runtime
Expand Down
3 changes: 2 additions & 1 deletion src/ui/ToolchainSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { showReloadExtensionNotification } from "./ReloadExtension";
import { SwiftToolchain } from "../toolchain/toolchain";
import configuration from "../configuration";
import { Commands } from "../commands";
import { Swiftly } from "../toolchain/swiftly";

/**
* Open the installation page on Swift.org
Expand Down Expand Up @@ -192,7 +193,7 @@ async function getQuickPickItems(
return result;
});
// Find any Swift toolchains installed via Swiftly
const swiftlyToolchains = (await SwiftToolchain.getSwiftlyToolchainInstalls())
const swiftlyToolchains = (await Swiftly.listAvailableToolchains())
.reverse()
.map<SwiftToolchainItem>(toolchainPath => ({
type: "toolchain",
Expand Down
Loading