diff --git a/CHANGELOG.md b/CHANGELOG.md index d58a58e8d..4b231ebec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added Swiftly toolchain management support `.swift-version` files, and integration with the toolchain selection UI ([#1717](https://github.com/swiftlang/vscode-swift/pull/1717) - Added code lenses to run suites/tests, configurable with the `swift.showTestCodeLenses` setting ([#1698](https://github.com/swiftlang/vscode-swift/pull/1698)) ## 2.8.0 - 2025-07-14 diff --git a/package-lock.json b/package-lock.json index 0ba253a54..0d749c07a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "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" }, "devDependencies": { "@types/archiver": "^6.0.3", @@ -10752,6 +10753,14 @@ "dependencies": { "safe-buffer": "~5.2.0" } + }, + "node_modules/zod": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.5.tgz", + "integrity": "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } }, "dependencies": { @@ -18371,6 +18380,11 @@ } } } + }, + "zod": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.5.tgz", + "integrity": "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==" } } } diff --git a/package.json b/package.json index 112b4b594..0d9456c4b 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/toolchain/swiftly.ts b/src/toolchain/swiftly.ts new file mode 100644 index 000000000..139170691 --- /dev/null +++ b/src/toolchain/swiftly.ts @@ -0,0 +1,197 @@ +//===----------------------------------------------------------------------===// +// +// 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 "path"; +import { SwiftlyConfig } from "./ToolchainVersion"; +import * as fs from "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( + outputChannel?: vscode.OutputChannel + ): Promise { + if (!Swiftly.isSupported()) { + return undefined; + } + try { + const { stdout } = await execFile("swiftly", ["--version"]); + return Version.fromString(stdout.trim()); + } catch (error) { + outputChannel?.appendLine(`Failed to retrieve Swiftly version: ${error}`); + return undefined; + } + } + + /** + * Finds the list of toolchains managed by Swiftly. + * + * @returns an array of toolchain paths + */ + public static async listAvailableToolchains( + outputChannel?: vscode.OutputChannel + ): Promise { + if (!this.isSupported()) { + return []; + } + const version = await Swiftly.version(outputChannel); + if (!version) { + outputChannel?.appendLine("Swiftly is not installed"); + return []; + } + + if (version.isLessThan(new Version(1, 1, 0))) { + return await Swiftly.getToolchainInstallLegacy(outputChannel); + } + + return await Swiftly.getListAvailableToolchains(outputChannel); + } + + private static async getListAvailableToolchains( + outputChannel?: vscode.OutputChannel + ): Promise { + 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) { + outputChannel?.appendLine(`Failed to retrieve Swiftly installations: ${error}`); + throw new Error( + `Failed to retrieve Swiftly installations from disk: ${(error as Error).message}` + ); + } + } + + private static async getToolchainInstallLegacy(outputChannel?: vscode.OutputChannel) { + 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) { + outputChannel?.appendLine(`Failed to retrieve Swiftly installations: ${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( + outputChannel?: vscode.OutputChannel, + cwd?: vscode.Uri + ): Promise { + 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) { + outputChannel?.appendLine(`Failed to retrieve Swiftly installations: ${err}`); + 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 { + 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); + } +} diff --git a/src/toolchain/toolchain.ts b/src/toolchain/toolchain.ts index d3a1370ab..96321590f 100644 --- a/src/toolchain/toolchain.ts +++ b/src/toolchain/toolchain.ts @@ -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. */ @@ -123,7 +122,7 @@ export class SwiftToolchain { outputChannel?: vscode.OutputChannel ): Promise { const swiftFolderPath = await this.getSwiftFolderPath(folder, outputChannel); - const toolchainPath = await this.getToolchainPath(swiftFolderPath, folder); + const toolchainPath = await this.getToolchainPath(swiftFolderPath, folder, outputChannel); const targetInfo = await this.getSwiftTargetInfo( this._getToolchainExecutable(toolchainPath, "swift") ); @@ -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 { - // 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 { - 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. * @@ -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"); } @@ -659,7 +610,11 @@ export class SwiftToolchain { /** * @returns path to Toolchain folder */ - private static async getToolchainPath(swiftPath: string, cwd?: vscode.Uri): Promise { + private static async getToolchainPath( + swiftPath: string, + cwd?: vscode.Uri, + channel?: vscode.OutputChannel + ): Promise { try { switch (process.platform) { case "darwin": { @@ -668,7 +623,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"); } @@ -679,7 +634,7 @@ export class SwiftToolchain { return path.dirname(configuration.path); } - const swiftlyToolchainLocation = await this.swiftlyToolchain(cwd); + const swiftlyToolchainLocation = await Swiftly.toolchain(channel, cwd); if (swiftlyToolchainLocation) { return swiftlyToolchainLocation; } @@ -699,41 +654,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 { - 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 diff --git a/src/ui/ToolchainSelection.ts b/src/ui/ToolchainSelection.ts index 41e8bfcfb..2cdb2a9bb 100644 --- a/src/ui/ToolchainSelection.ts +++ b/src/ui/ToolchainSelection.ts @@ -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 @@ -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(toolchainPath => ({ type: "toolchain", diff --git a/test/unit-tests/toolchain/swiftly.test.ts b/test/unit-tests/toolchain/swiftly.test.ts new file mode 100644 index 000000000..3f2838253 --- /dev/null +++ b/test/unit-tests/toolchain/swiftly.test.ts @@ -0,0 +1,106 @@ +//===----------------------------------------------------------------------===// +// +// 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 { expect } from "chai"; +import { Swiftly } from "../../../src/toolchain/swiftly"; +import * as utilities from "../../../src/utilities/utilities"; +import { mockGlobalModule, mockGlobalValue } from "../../MockUtils"; + +suite("Swiftly Unit Tests", () => { + const mockUtilities = mockGlobalModule(utilities); + const mockedPlatform = mockGlobalValue(process, "platform"); + + suite("getSwiftlyToolchainInstalls", () => { + test("should return toolchain names from list-available command for version 1.1.0", async () => { + // Mock version check to return 1.1.0 + mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ + stdout: "1.1.0\n", + stderr: "", + }); + + // Mock list-available command with JSON output + const jsonOutput = { + toolchains: [ + { + inUse: true, + installed: true, + isDefault: true, + name: "swift-5.9.0-RELEASE", + version: { + major: 5, + minor: 9, + patch: 0, + type: "stable", + }, + }, + { + inUse: false, + installed: true, + isDefault: false, + name: "swift-5.8.0-RELEASE", + version: { + major: 5, + minor: 8, + patch: 0, + type: "stable", + }, + }, + { + inUse: false, + installed: false, + isDefault: false, + name: "swift-DEVELOPMENT-SNAPSHOT-2023-10-15-a", + version: { + major: 5, + minor: 10, + branch: "development", + date: "2023-10-15", + type: "snapshot", + }, + }, + ], + }; + + mockUtilities.execFile + .withArgs("swiftly", ["list-available", "--format=json"]) + .resolves({ + stdout: JSON.stringify(jsonOutput), + stderr: "", + }); + + const result = await Swiftly.listAvailableToolchains(); + + expect(result).to.deep.equal([ + "swift-5.9.0-RELEASE", + "swift-5.8.0-RELEASE", + "swift-DEVELOPMENT-SNAPSHOT-2023-10-15-a", + ]); + + expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", ["--version"]); + expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", [ + "list-available", + "--format=json", + ]); + }); + + test("should return empty array when platform is not supported", async () => { + mockedPlatform.setValue("win32"); + + const result = await Swiftly.listAvailableToolchains(); + + expect(result).to.deep.equal([]); + expect(mockUtilities.execFile).not.have.been.called; + }); + }); +}); diff --git a/test/unit-tests/toolchain/toolchain.test.ts b/test/unit-tests/toolchain/toolchain.test.ts index ac0345b53..3e12571db 100644 --- a/test/unit-tests/toolchain/toolchain.test.ts +++ b/test/unit-tests/toolchain/toolchain.test.ts @@ -19,6 +19,7 @@ import * as utilities from "../../../src/utilities/utilities"; import { SwiftToolchain } from "../../../src/toolchain/toolchain"; import { Version } from "../../../src/utilities/version"; import { mockGlobalModule, mockGlobalValue } from "../../MockUtils"; +import { Swiftly } from "../../../src/toolchain/swiftly"; suite("SwiftToolchain Unit Test Suite", () => { const mockedUtilities = mockGlobalModule(utilities); @@ -26,9 +27,10 @@ suite("SwiftToolchain Unit Test Suite", () => { setup(() => { mockFS({}); - mockedUtilities.execFile.rejects( - new Error("execFile was not properly mocked for the test") - ); + mockedUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ + stdout: "1.0.0\n", + stderr: "", + }); }); teardown(() => { @@ -311,7 +313,7 @@ suite("SwiftToolchain Unit Test Suite", () => { }), }); - const toolchains = await SwiftToolchain.getSwiftlyToolchainInstalls(); + const toolchains = await Swiftly.listAvailableToolchains(); expect(toolchains).to.deep.equal([ path.join(mockHomeDir, "toolchains", "swift-5.9.0"), path.join(mockHomeDir, "toolchains", "swift-6.0.0"), @@ -329,7 +331,7 @@ suite("SwiftToolchain Unit Test Suite", () => { }), }); - const toolchains = await SwiftToolchain.getSwiftlyToolchainInstalls(); + const toolchains = await Swiftly.listAvailableToolchains(); expect(toolchains).to.deep.equal([ path.join(mockHomeDir, "toolchains", "swift-5.9.0"), path.join(mockHomeDir, "toolchains", "swift-6.0.0"), @@ -340,7 +342,7 @@ suite("SwiftToolchain Unit Test Suite", () => { mockedPlatform.setValue("linux"); mockedEnv.setValue({}); - const toolchains = await SwiftToolchain.getSwiftlyToolchainInstalls(); + const toolchains = await Swiftly.listAvailableToolchains(); expect(toolchains).to.be.empty; }); @@ -351,8 +353,8 @@ suite("SwiftToolchain Unit Test Suite", () => { mockFS({}); - await expect(SwiftToolchain.getSwiftlyToolchainInstalls()).to.be.rejectedWith( - "Failed to retrieve Swiftly installations from disk." + await expect(Swiftly.listAvailableToolchains()).to.be.rejectedWith( + "Failed to retrieve Swiftly installations from disk: ENOENT, no such file or directory '/home/user/.swiftly/config.json'" ); }); @@ -367,13 +369,13 @@ suite("SwiftToolchain Unit Test Suite", () => { }), }); - const toolchains = await SwiftToolchain.getSwiftlyToolchainInstalls(); + const toolchains = await Swiftly.listAvailableToolchains(); expect(toolchains).to.be.empty; }); test("returns empty array on Windows", async () => { mockedPlatform.setValue("win32"); - const toolchains = await SwiftToolchain.getSwiftlyToolchainInstalls(); + const toolchains = await Swiftly.listAvailableToolchains(); expect(toolchains).to.be.empty; }); @@ -388,7 +390,7 @@ suite("SwiftToolchain Unit Test Suite", () => { }), }); - const toolchains = await SwiftToolchain.getSwiftlyToolchainInstalls(); + const toolchains = await Swiftly.listAvailableToolchains(); expect(toolchains).to.deep.equal([ path.join(mockHomeDir, "toolchains", "swift-5.9.0"), path.join(mockHomeDir, "toolchains", "swift-6.0.0"),