diff --git a/src/commands.ts b/src/commands.ts index 772725020..5866ecce2 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -63,14 +63,15 @@ import { generateSourcekitConfiguration } from "./commands/generateSourcekitConf export type WorkspaceContextWithToolchain = WorkspaceContext & { toolchain: SwiftToolchain }; export function registerToolchainCommands( - toolchain: SwiftToolchain | undefined + toolchain: SwiftToolchain | undefined, + cwd?: vscode.Uri ): vscode.Disposable[] { return [ vscode.commands.registerCommand("swift.createNewProject", () => createNewProject(toolchain) ), vscode.commands.registerCommand("swift.selectToolchain", () => - showToolchainSelectionQuickPick(toolchain) + showToolchainSelectionQuickPick(toolchain, cwd) ), vscode.commands.registerCommand("swift.pickProcess", configuration => pickProcess(configuration) diff --git a/src/extension.ts b/src/extension.ts index 33ef8a0b3..d05e1efd3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -81,7 +81,9 @@ export async function activate(context: vscode.ExtensionContext): Promise { context.subscriptions.push(new SwiftEnvironmentVariablesManager(context)); context.subscriptions.push(SwiftTerminalProfileProvider.register()); - context.subscriptions.push(...commands.registerToolchainCommands(toolchain)); + context.subscriptions.push( + ...commands.registerToolchainCommands(toolchain, workspaceContext.currentFolder?.folder) + ); // Watch for configuration changes the trigger a reload of the extension if necessary. context.subscriptions.push( diff --git a/src/toolchain/swiftly.ts b/src/toolchain/swiftly.ts index 139170691..1e2843cd8 100644 --- a/src/toolchain/swiftly.ts +++ b/src/toolchain/swiftly.ts @@ -20,26 +20,25 @@ import * as vscode from "vscode"; import { Version } from "../utilities/version"; import { z } from "zod"; -const ListAvailableResult = z.object({ +const ListResult = 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(), + major: z.number().optional(), + minor: z.number().optional(), patch: z.number().optional(), + name: z.string(), type: z.literal("stable"), }), z.object({ - major: z.number(), - minor: z.number(), + major: z.number().optional(), + minor: z.number().optional(), branch: z.string(), date: z.string(), - + name: z.string(), type: z.literal("snapshot"), }), ]), @@ -47,6 +46,10 @@ const ListAvailableResult = z.object({ ), }); +const InUseVersionResult = z.object({ + version: z.string(), +}); + export class Swiftly { /** * Finds the version of Swiftly installed on the system. @@ -69,6 +72,27 @@ export class Swiftly { } } + /** + * Checks if the installed version of Swiftly supports JSON output. + * + * @returns `true` if JSON output is supported, `false` otherwise. + */ + private static async supportsJsonOutput( + outputChannel?: vscode.OutputChannel + ): Promise { + if (!Swiftly.isSupported()) { + return false; + } + try { + const { stdout } = await execFile("swiftly", ["--version"]); + const version = Version.fromString(stdout.trim()); + return version?.isGreaterThanOrEqual(new Version(1, 1, 0)) ?? false; + } catch (error) { + outputChannel?.appendLine(`Failed to check Swiftly JSON support: ${error}`); + return false; + } + } + /** * Finds the list of toolchains managed by Swiftly. * @@ -86,7 +110,7 @@ export class Swiftly { return []; } - if (version.isLessThan(new Version(1, 1, 0))) { + if (!(await Swiftly.supportsJsonOutput(outputChannel))) { return await Swiftly.getToolchainInstallLegacy(outputChannel); } @@ -97,14 +121,12 @@ export class Swiftly { 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); + const { stdout } = await execFile("swiftly", ["list", "--format=json"]); + const response = ListResult.parse(JSON.parse(stdout)); + return response.toolchains.map(t => t.version.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}` - ); + return []; } } @@ -137,13 +159,39 @@ export class Swiftly { return process.platform === "linux" || process.platform === "darwin"; } - public static async inUseLocation(swiftlyPath: string, cwd?: vscode.Uri) { + public static async inUseLocation(swiftlyPath: string = "swiftly", cwd?: vscode.Uri) { const { stdout: inUse } = await execFile(swiftlyPath, ["use", "--print-location"], { cwd: cwd?.fsPath, }); return inUse.trimEnd(); } + public static async inUseVersion( + swiftlyPath: string = "swiftly", + cwd?: vscode.Uri + ): Promise { + if (!this.isSupported()) { + throw new Error("Swiftly is not supported on this platform"); + } + + if (!(await Swiftly.supportsJsonOutput())) { + return undefined; + } + + const { stdout } = await execFile(swiftlyPath, ["use", "--format=json"], { + cwd: cwd?.fsPath, + }); + const result = InUseVersionResult.parse(JSON.parse(stdout)); + return result.version; + } + + public static async use(version: string): Promise { + if (!this.isSupported()) { + throw new Error("Swiftly is not supported on this platform"); + } + await execFile("swiftly", ["use", version]); + } + /** * Determine if Swiftly is being used to manage the active toolchain and if so, return * the path to the active toolchain. diff --git a/src/toolchain/toolchain.ts b/src/toolchain/toolchain.ts index 96321590f..6914d037e 100644 --- a/src/toolchain/toolchain.ts +++ b/src/toolchain/toolchain.ts @@ -112,7 +112,8 @@ export class SwiftToolchain { public customSDK?: string, public xcTestPath?: string, public swiftTestingPath?: string, - public swiftPMTestingHelperPath?: string + public swiftPMTestingHelperPath?: string, + public isSwiftlyManaged: boolean = false // true if this toolchain is managed by Swiftly ) { this.swiftVersionString = targetInfo.compilerVersion; } @@ -121,7 +122,10 @@ export class SwiftToolchain { folder?: vscode.Uri, outputChannel?: vscode.OutputChannel ): Promise { - const swiftFolderPath = await this.getSwiftFolderPath(folder, outputChannel); + const { path: swiftFolderPath, isSwiftlyManaged } = await this.getSwiftFolderPath( + folder, + outputChannel + ); const toolchainPath = await this.getToolchainPath(swiftFolderPath, folder, outputChannel); const targetInfo = await this.getSwiftTargetInfo( this._getToolchainExecutable(toolchainPath, "swift") @@ -159,7 +163,8 @@ export class SwiftToolchain { customSDK, xcTestPath, swiftTestingPath, - swiftPMTestingHelperPath + swiftPMTestingHelperPath, + isSwiftlyManaged ); } @@ -518,7 +523,7 @@ export class SwiftToolchain { private static async getSwiftFolderPath( cwd?: vscode.Uri, outputChannel?: vscode.OutputChannel - ): Promise { + ): Promise<{ path: string; isSwiftlyManaged: boolean }> { try { let swift: string; if (configuration.path !== "") { @@ -564,18 +569,24 @@ export class SwiftToolchain { } // swift may be a symbolic link let realSwift = await fs.realpath(swift); + let isSwiftlyManaged = false; + if (path.basename(realSwift) === "swiftly") { try { const inUse = await Swiftly.inUseLocation(realSwift, cwd); if (inUse) { realSwift = path.join(inUse, "usr", "bin", "swift"); + isSwiftlyManaged = true; } } catch { // Ignore, will fall back to original path } } const swiftPath = expandFilePathTilde(path.dirname(realSwift)); - return await this.getSwiftEnvPath(swiftPath); + return { + path: await this.getSwiftEnvPath(swiftPath), + isSwiftlyManaged, + }; } catch (error) { outputChannel?.appendLine(`Failed to find swift executable: ${error}`); throw Error("Failed to find swift executable"); diff --git a/src/ui/ToolchainSelection.ts b/src/ui/ToolchainSelection.ts index 2cdb2a9bb..9cff98ebb 100644 --- a/src/ui/ToolchainSelection.ts +++ b/src/ui/ToolchainSelection.ts @@ -98,25 +98,27 @@ export async function selectToolchain() { } /** A {@link vscode.QuickPickItem} that contains the path to an installed Swift toolchain */ -type SwiftToolchainItem = PublicSwiftToolchainItem | XcodeToolchainItem; +type SwiftToolchainItem = PublicSwiftToolchainItem | XcodeToolchainItem | SwiftlyToolchainItem; /** Common properties for a {@link vscode.QuickPickItem} that represents a Swift toolchain */ interface BaseSwiftToolchainItem extends vscode.QuickPickItem { type: "toolchain"; - toolchainPath: string; - swiftFolderPath: string; onDidSelect?(): Promise; } /** A {@link vscode.QuickPickItem} for a Swift toolchain that has been installed manually */ interface PublicSwiftToolchainItem extends BaseSwiftToolchainItem { - category: "public" | "swiftly"; + category: "public"; + toolchainPath: string; + swiftFolderPath: string; } /** A {@link vscode.QuickPickItem} for a Swift toolchain provided by an installed Xcode application */ interface XcodeToolchainItem extends BaseSwiftToolchainItem { category: "xcode"; xcodePath: string; + toolchainPath: string; + swiftFolderPath: string; } /** A {@link vscode.QuickPickItem} that performs an action for the user */ @@ -125,6 +127,11 @@ interface ActionItem extends vscode.QuickPickItem { run(): Promise; } +interface SwiftlyToolchainItem extends BaseSwiftToolchainItem { + category: "swiftly"; + version: string; +} + /** A {@link vscode.QuickPickItem} that separates items in the UI */ class SeparatorItem implements vscode.QuickPickItem { readonly type = "separator"; @@ -146,7 +153,8 @@ type SelectToolchainItem = SwiftToolchainItem | ActionItem | SeparatorItem; * @returns an array of {@link SelectToolchainItem} */ async function getQuickPickItems( - activeToolchain: SwiftToolchain | undefined + activeToolchain: SwiftToolchain | undefined, + cwd?: vscode.Uri ): Promise { // Find any Xcode installations on the system const xcodes = (await SwiftToolchain.findXcodeInstalls()) @@ -195,18 +203,43 @@ async function getQuickPickItems( // Find any Swift toolchains installed via Swiftly const swiftlyToolchains = (await Swiftly.listAvailableToolchains()) .reverse() - .map(toolchainPath => ({ + .map(toolchainPath => ({ type: "toolchain", - category: "swiftly", label: path.basename(toolchainPath), - detail: toolchainPath, - toolchainPath: path.join(toolchainPath, "usr"), - swiftFolderPath: path.join(toolchainPath, "usr", "bin"), + category: "swiftly", + version: path.basename(toolchainPath), + onDidSelect: async () => { + try { + await Swiftly.use(toolchainPath); + void showReloadExtensionNotification( + "Changing the Swift path requires Visual Studio Code be reloaded." + ); + } catch (error) { + void vscode.window.showErrorMessage( + `Failed to switch Swiftly toolchain: ${error}` + ); + } + }, })); // Mark which toolchain is being actively used if (activeToolchain) { + const currentSwiftlyVersion = activeToolchain.isSwiftlyManaged + ? await Swiftly.inUseVersion("swiftly", cwd) + : undefined; const toolchainInUse = [...xcodes, ...toolchains, ...swiftlyToolchains].find(toolchain => { - return toolchain.toolchainPath === activeToolchain.toolchainPath; + if (currentSwiftlyVersion) { + if (toolchain.category !== "swiftly") { + return false; + } + + // For Swiftly toolchains, check if the label matches the active toolchain version + return currentSwiftlyVersion === toolchain.label; + } + // For non-Swiftly toolchains, check if the toolchain path matches + return ( + (toolchain as PublicSwiftToolchainItem | XcodeToolchainItem).toolchainPath === + activeToolchain.toolchainPath + ); }); if (toolchainInUse) { toolchainInUse.description = "$(check) in use"; @@ -262,10 +295,13 @@ async function getQuickPickItems( * * @param activeToolchain the {@link WorkspaceContext} */ -export async function showToolchainSelectionQuickPick(activeToolchain: SwiftToolchain | undefined) { +export async function showToolchainSelectionQuickPick( + activeToolchain: SwiftToolchain | undefined, + cwd?: vscode.Uri +) { let xcodePaths: string[] = []; const selected = await vscode.window.showQuickPick( - getQuickPickItems(activeToolchain).then(result => { + getQuickPickItems(activeToolchain, cwd).then(result => { xcodePaths = result .filter((i): i is XcodeToolchainItem => "category" in i && i.category === "xcode") .map(xcode => xcode.xcodePath); @@ -303,8 +339,23 @@ export async function showToolchainSelectionQuickPick(activeToolchain: SwiftTool }); } } - // Update the toolchain path - const isUpdated = await setToolchainPath(selected.swiftFolderPath, developerDir); + // Update the toolchain path` + let swiftPath: string | undefined; + + // Handle Swiftly toolchains specially + if (selected.category === "swiftly") { + try { + swiftPath = undefined; + } catch (error) { + void vscode.window.showErrorMessage(`Failed to switch Swiftly toolchain: ${error}`); + return; + } + } else { + // For non-Swiftly toolchains, use the swiftFolderPath + swiftPath = selected.swiftFolderPath; + } + + const isUpdated = await setToolchainPath(swiftPath, developerDir); if (isUpdated && selected.onDidSelect) { await selected.onDidSelect(); } diff --git a/test/unit-tests/toolchain/swiftly.test.ts b/test/unit-tests/toolchain/swiftly.test.ts index 71cd193ac..fa48bea81 100644 --- a/test/unit-tests/toolchain/swiftly.test.ts +++ b/test/unit-tests/toolchain/swiftly.test.ts @@ -38,50 +38,45 @@ suite("Swiftly Unit Tests", () => { toolchains: [ { inUse: true, - installed: true, isDefault: true, - name: "swift-5.9.0-RELEASE", version: { major: 5, minor: 9, patch: 0, + name: "swift-5.9.0-RELEASE", type: "stable", }, }, { inUse: false, - installed: true, isDefault: false, - name: "swift-5.8.0-RELEASE", version: { major: 5, minor: 8, patch: 0, + name: "swift-5.8.0-RELEASE", 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", + name: "swift-DEVELOPMENT-SNAPSHOT-2023-10-15-a", type: "snapshot", }, }, ], }; - mockUtilities.execFile - .withArgs("swiftly", ["list-available", "--format=json"]) - .resolves({ - stdout: JSON.stringify(jsonOutput), - stderr: "", - }); + mockUtilities.execFile.withArgs("swiftly", ["list", "--format=json"]).resolves({ + stdout: JSON.stringify(jsonOutput), + stderr: "", + }); const result = await Swiftly.listAvailableToolchains(); @@ -93,7 +88,7 @@ suite("Swiftly Unit Tests", () => { expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", ["--version"]); expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", [ - "list-available", + "list", "--format=json", ]); });