Skip to content

Commit 111640f

Browse files
authored
Prompt to automatically install missing toolchain (#1855)
If a .swift-version exists in a project that specifies a toolchain the user doesn't have installed, prompt them to install it on extension launch. If they cancel the dialog, fall back to using whatever toolchain is already installed, basically ignoring the .swift-version for this activation or until the .swift-version is changed again. Issue: #1852
1 parent 9c1ac4d commit 111640f

File tree

4 files changed

+207
-12
lines changed

4 files changed

+207
-12
lines changed

src/commands/installSwiftlyToolchain.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as vscode from "vscode";
1515
import { QuickPickItem } from "vscode";
1616

1717
import { WorkspaceContext } from "../WorkspaceContext";
18+
import { SwiftLogger } from "../logging/SwiftLogger";
1819
import {
1920
Swiftly,
2021
SwiftlyProgressData,
@@ -29,11 +30,26 @@ interface SwiftlyToolchainItem extends QuickPickItem {
2930
}
3031

3132
async function downloadAndInstallToolchain(selected: SwiftlyToolchainItem, ctx: WorkspaceContext) {
33+
return await installSwiftlyToolchainVersion(selected.toolchain.version.name, ctx.logger, true);
34+
}
35+
36+
/**
37+
* Installs a Swiftly toolchain by version string
38+
* @param version The toolchain version to install
39+
* @param logger Optional logger for error reporting
40+
* @param showReloadNotification Whether to show reload notification after installation
41+
* @returns Promise<boolean> true if installation succeeded, false otherwise
42+
*/
43+
export async function installSwiftlyToolchainVersion(
44+
version: string,
45+
logger?: SwiftLogger,
46+
showReloadNotification: boolean = true
47+
): Promise<boolean> {
3248
try {
3349
await vscode.window.withProgress(
3450
{
3551
location: vscode.ProgressLocation.Notification,
36-
title: `Installing Swift ${selected.toolchain.version.name}`,
52+
title: `Installing Swift ${version}`,
3753
cancellable: false,
3854
},
3955
async progress => {
@@ -42,7 +58,7 @@ async function downloadAndInstallToolchain(selected: SwiftlyToolchainItem, ctx:
4258
let lastProgress = 0;
4359

4460
await Swiftly.installToolchain(
45-
selected.toolchain.version.name,
61+
version,
4662
(progressData: SwiftlyProgressData) => {
4763
if (
4864
progressData.step?.percent !== undefined &&
@@ -58,7 +74,7 @@ async function downloadAndInstallToolchain(selected: SwiftlyToolchainItem, ctx:
5874
lastProgress = progressData.step.percent;
5975
}
6076
},
61-
ctx.logger
77+
logger
6278
);
6379

6480
progress.report({
@@ -67,14 +83,17 @@ async function downloadAndInstallToolchain(selected: SwiftlyToolchainItem, ctx:
6783
});
6884
}
6985
);
70-
void showReloadExtensionNotification(
71-
`Swift ${selected.toolchain.version.name} has been installed and activated. Visual Studio Code needs to be reloaded.`
72-
);
86+
87+
if (showReloadNotification) {
88+
void showReloadExtensionNotification(
89+
`Swift ${version} has been installed and activated. Visual Studio Code needs to be reloaded.`
90+
);
91+
}
92+
return true;
7393
} catch (error) {
74-
ctx.logger?.error(`Failed to install Swift ${selected.toolchain.version.name}: ${error}`);
75-
void vscode.window.showErrorMessage(
76-
`Failed to install Swift ${selected.toolchain.version.name}: ${error}`
77-
);
94+
logger?.error(`Failed to install Swift ${version}: ${error}`);
95+
void vscode.window.showErrorMessage(`Failed to install Swift ${version}: ${error}`);
96+
return false;
7897
}
7998
}
8099

src/toolchain/swiftly.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import * as Stream from "stream";
2020
import * as vscode from "vscode";
2121
import { z } from "zod/v4/mini";
2222

23+
// Import the reusable installation function
24+
import { installSwiftlyToolchainVersion } from "../commands/installSwiftlyToolchain";
2325
import { SwiftLogger } from "../logging/SwiftLogger";
26+
import { showMissingToolchainDialog } from "../ui/ToolchainSelection";
2427
import { findBinaryPath } from "../utilities/shell";
2528
import { ExecFileError, execFile, execFileStreamOutput } from "../utilities/utilities";
2629
import { Version } from "../utilities/version";
@@ -127,6 +130,56 @@ export interface PostInstallValidationResult {
127130
invalidCommands?: string[];
128131
}
129132

133+
export interface MissingToolchainError {
134+
version: string;
135+
originalError: string;
136+
}
137+
138+
/**
139+
* Parses Swiftly error message to detect missing toolchain scenarios
140+
* @param stderr The stderr output from swiftly command
141+
* @returns MissingToolchainError if this is a missing toolchain error, undefined otherwise
142+
*/
143+
export function parseSwiftlyMissingToolchainError(
144+
stderr: string
145+
): MissingToolchainError | undefined {
146+
// Parse error message like: "uses toolchain version 6.1.2, but it doesn't match any of the installed toolchains"
147+
const versionMatch = stderr.match(/uses toolchain version ([0-9.]+(?:-[a-zA-Z0-9-]+)*)/);
148+
if (versionMatch && stderr.includes("doesn't match any of the installed toolchains")) {
149+
return {
150+
version: versionMatch[1],
151+
originalError: stderr,
152+
};
153+
}
154+
return undefined;
155+
}
156+
157+
/**
158+
* Attempts to automatically install a missing Swiftly toolchain with user consent
159+
* @param version The toolchain version to install
160+
* @param logger Optional logger for error reporting
161+
* @param folder Optional folder context
162+
* @returns Promise<boolean> true if toolchain was successfully installed, false otherwise
163+
*/
164+
export async function handleMissingSwiftlyToolchain(
165+
version: string,
166+
logger?: SwiftLogger,
167+
folder?: vscode.Uri
168+
): Promise<boolean> {
169+
logger?.info(`Attempting to handle missing toolchain: ${version}`);
170+
171+
// Ask user for permission
172+
const userConsent = await showMissingToolchainDialog(version, folder);
173+
if (!userConsent) {
174+
logger?.info(`User declined to install missing toolchain: ${version}`);
175+
return false;
176+
}
177+
178+
// Use the existing installation function without showing reload notification
179+
// (since we want to continue the current operation)
180+
return await installSwiftlyToolchainVersion(version, logger, false);
181+
}
182+
130183
export class Swiftly {
131184
/**
132185
* Finds the version of Swiftly installed on the system.
@@ -287,7 +340,39 @@ export class Swiftly {
287340
} catch (err: unknown) {
288341
logger?.error(`Failed to retrieve Swiftly installations: ${err}`);
289342
const error = err as ExecFileError;
290-
// Its possible the toolchain in .swift-version is misconfigured or doesn't exist.
343+
344+
// Check if this is a missing toolchain error
345+
const missingToolchainError = parseSwiftlyMissingToolchainError(error.stderr);
346+
if (missingToolchainError) {
347+
// Attempt automatic installation
348+
const installed = await handleMissingSwiftlyToolchain(
349+
missingToolchainError.version,
350+
logger,
351+
cwd
352+
);
353+
354+
if (installed) {
355+
// Retry toolchain location after successful installation
356+
try {
357+
const retryInUse = await Swiftly.inUseLocation("swiftly", cwd);
358+
if (retryInUse.length > 0) {
359+
return path.join(retryInUse, "usr");
360+
}
361+
} catch (retryError) {
362+
logger?.error(
363+
`Failed to use toolchain after installation: ${retryError}`
364+
);
365+
}
366+
} else {
367+
// User declined installation - gracefully fall back to global toolchain
368+
logger?.info(
369+
`Falling back to global toolchain after user declined installation of missing toolchain: ${missingToolchainError.version}`
370+
);
371+
return undefined;
372+
}
373+
}
374+
375+
// Fall back to original error handling for non-missing-toolchain errors
291376
void vscode.window.showErrorMessage(
292377
`Failed to load toolchain from Swiftly: ${error.stderr}`
293378
);

src/ui/ToolchainSelection.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,25 @@ export async function showToolchainError(folder?: vscode.Uri): Promise<boolean>
100100
return false;
101101
}
102102

103+
/**
104+
* Shows a dialog asking user permission to install a missing Swiftly toolchain
105+
* @param version The toolchain version to install
106+
* @param folder Optional folder context for the error
107+
* @returns Promise<boolean> true if user agrees to install, false otherwise
108+
*/
109+
export async function showMissingToolchainDialog(
110+
version: string,
111+
folder?: vscode.Uri
112+
): Promise<boolean> {
113+
const folderName = folder ? `${FolderContext.uriName(folder)}: ` : "";
114+
const message =
115+
`${folderName}Swift version ${version} is required but not installed. ` +
116+
`Would you like to automatically install it using Swiftly?`;
117+
118+
const choice = await vscode.window.showWarningMessage(message, "Install Toolchain", "Cancel");
119+
return choice === "Install Toolchain";
120+
}
121+
103122
export async function selectToolchain() {
104123
await vscode.commands.executeCommand(Commands.SELECT_TOOLCHAIN);
105124
}

test/unit-tests/toolchain/swiftly.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ import { match } from "sinon";
1919
import * as vscode from "vscode";
2020

2121
import * as SwiftOutputChannelModule from "@src/logging/SwiftOutputChannel";
22-
import { Swiftly } from "@src/toolchain/swiftly";
22+
import {
23+
Swiftly,
24+
handleMissingSwiftlyToolchain,
25+
parseSwiftlyMissingToolchainError,
26+
} from "@src/toolchain/swiftly";
2327
import * as utilities from "@src/utilities/utilities";
2428

2529
import { mockGlobalModule, mockGlobalObject, mockGlobalValue } from "../../MockUtils";
@@ -1031,4 +1035,72 @@ apt-get -y install libncurses5-dev
10311035
expect(mockUtilities.execFile).to.not.have.been.calledWith("pkexec", match.array);
10321036
});
10331037
});
1038+
1039+
suite("Missing Toolchain Handling", () => {
1040+
test("parseSwiftlyMissingToolchainError parses version correctly", () => {
1041+
const stderr =
1042+
"The swift version file uses toolchain version 6.1.2, but it doesn't match any of the installed toolchains. You can install the toolchain with `swiftly install`.";
1043+
const result = parseSwiftlyMissingToolchainError(stderr);
1044+
expect(result?.version).to.equal("6.1.2");
1045+
expect(result?.originalError).to.equal(stderr);
1046+
});
1047+
1048+
test("parseSwiftlyMissingToolchainError returns undefined for other errors", () => {
1049+
const stderr = "Some other error message";
1050+
const result = parseSwiftlyMissingToolchainError(stderr);
1051+
expect(result).to.be.undefined;
1052+
});
1053+
1054+
test("parseSwiftlyMissingToolchainError handles snapshot versions", () => {
1055+
const stderr =
1056+
"uses toolchain version 6.1-snapshot-2024-12-01, but it doesn't match any of the installed toolchains";
1057+
const result = parseSwiftlyMissingToolchainError(stderr);
1058+
expect(result?.version).to.equal("6.1-snapshot-2024-12-01");
1059+
});
1060+
1061+
test("parseSwiftlyMissingToolchainError handles versions with hyphens", () => {
1062+
const stderr =
1063+
"uses toolchain version 6.0-dev, but it doesn't match any of the installed toolchains";
1064+
const result = parseSwiftlyMissingToolchainError(stderr);
1065+
expect(result?.version).to.equal("6.0-dev");
1066+
});
1067+
});
1068+
1069+
suite("handleMissingSwiftlyToolchain", () => {
1070+
const mockWindow = mockGlobalObject(vscode, "window");
1071+
const mockedUtilities = mockGlobalModule(utilities);
1072+
const mockSwiftlyInstallToolchain = mockGlobalValue(Swiftly, "installToolchain");
1073+
1074+
test("handleMissingSwiftlyToolchain returns false when user declines installation", async () => {
1075+
mockWindow.showWarningMessage.resolves(undefined); // User cancels/declines
1076+
const result = await handleMissingSwiftlyToolchain("6.1.2");
1077+
expect(result).to.be.false;
1078+
});
1079+
1080+
test("handleMissingSwiftlyToolchain returns true when user accepts and installation succeeds", async () => {
1081+
// User accepts the installation
1082+
mockWindow.showWarningMessage.resolves("Install Toolchain" as any);
1083+
1084+
// Mock successful installation with progress
1085+
mockWindow.withProgress.callsFake(async (_options, task) => {
1086+
const mockProgress = { report: () => {} };
1087+
const mockToken = {
1088+
isCancellationRequested: false,
1089+
onCancellationRequested: () => ({ dispose: () => {} }),
1090+
};
1091+
await task(mockProgress, mockToken);
1092+
return true;
1093+
});
1094+
1095+
mockSwiftlyInstallToolchain.setValue(() => Promise.resolve(void 0));
1096+
1097+
// Mock the installSwiftlyToolchainVersion to succeed
1098+
mockedUtilities.execFile
1099+
.withArgs("swiftly", match.any)
1100+
.resolves({ stdout: "", stderr: "" });
1101+
1102+
const result = await handleMissingSwiftlyToolchain("6.1.2");
1103+
expect(result).to.be.true;
1104+
});
1105+
});
10341106
});

0 commit comments

Comments
 (0)