Skip to content

Fixed JSON parsing error while selecting toolchain #1743

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

Merged
merged 4 commits into from
Jul 25, 2025
Merged
Show file tree
Hide file tree
Changes from 3 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
64 changes: 49 additions & 15 deletions src/toolchain/swiftly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}),
]),
Expand Down Expand Up @@ -97,14 +96,12 @@ export class Swiftly {
outputChannel?: vscode.OutputChannel
): 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);
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 [];
}
}

Expand Down Expand Up @@ -137,13 +134,20 @@ 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 use(version: string): Promise<void> {
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.
Expand Down Expand Up @@ -178,6 +182,36 @@ export class Swiftly {
return undefined;
}

/**
* Returns the home directory for Swiftly.
*
* @returns The path to the Swiftly home directory.
*/
static getHomeDir(): string | undefined {
return process.env["SWIFTLY_HOME_DIR"];
}

/**
* Returns the directory where Swift binaries managed by Swiftly are installed.
* This is a placeholder method and should be implemented based on your environment.
*
* @returns The path to the Swiftly binaries directory.
*/
static getBinDir(): string {
const overriddenBinDir = process.env["SWIFTLY_BIN_DIR"];
if (overriddenBinDir) {
return overriddenBinDir;
}

// If SWIFTLY_BIN_DIR is not set, use the default location based on SWIFTLY_HOME_DIR
// This assumes that the binaries are located in the "bin" subdirectory of SWIFTLY_HOME_DIR
const swiftlyHomeDir = Swiftly.getHomeDir();
if (!swiftlyHomeDir) {
throw new Error("Swiftly is not installed or SWIFTLY_HOME_DIR is not set.");
}
return path.join(swiftlyHomeDir, "bin");
}

/**
* Reads the Swiftly configuration file, if it exists.
*
Expand Down
21 changes: 16 additions & 5 deletions src/toolchain/toolchain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -121,7 +122,10 @@ export class SwiftToolchain {
folder?: vscode.Uri,
outputChannel?: vscode.OutputChannel
): Promise<SwiftToolchain> {
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")
Expand Down Expand Up @@ -159,7 +163,8 @@ export class SwiftToolchain {
customSDK,
xcTestPath,
swiftTestingPath,
swiftPMTestingHelperPath
swiftPMTestingHelperPath,
isSwiftlyManaged
);
}

Expand Down Expand Up @@ -518,7 +523,7 @@ export class SwiftToolchain {
private static async getSwiftFolderPath(
cwd?: vscode.Uri,
outputChannel?: vscode.OutputChannel
): Promise<string> {
): Promise<{ path: string; isSwiftlyManaged: boolean }> {
try {
let swift: string;
if (configuration.path !== "") {
Expand Down Expand Up @@ -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");
Expand Down
57 changes: 45 additions & 12 deletions src/ui/ToolchainSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}

/** 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 */
Expand All @@ -125,6 +127,11 @@ interface ActionItem extends vscode.QuickPickItem {
run(): Promise<void>;
}

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";
Expand Down Expand Up @@ -178,7 +185,6 @@ async function getQuickPickItems(
type: "toolchain",
category: "public",
label: path.basename(toolchainPath, ".xctoolchain"),
detail: toolchainPath,
toolchainPath: path.join(toolchainPath, "usr"),
swiftFolderPath: path.join(toolchainPath, "usr", "bin"),
};
Expand All @@ -195,18 +201,28 @@ async function getQuickPickItems(
// Find any Swift toolchains installed via Swiftly
const swiftlyToolchains = (await Swiftly.listAvailableToolchains())
.reverse()
.map<SwiftToolchainItem>(toolchainPath => ({
.map<SwiftlyToolchainItem>(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),
}));
// Mark which toolchain is being actively used
if (activeToolchain) {
const toolchainInUse = [...xcodes, ...toolchains, ...swiftlyToolchains].find(toolchain => {
return toolchain.toolchainPath === activeToolchain.toolchainPath;
if (activeToolchain.isSwiftlyManaged) {
if (toolchain.category !== "swiftly") {
return false;
}

// For Swiftly toolchains, check if the label matches the active toolchain version
return toolchain.label === activeToolchain.swiftVersion.toString();
}
// 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";
Expand Down Expand Up @@ -304,7 +320,24 @@ export async function showToolchainSelectionQuickPick(activeToolchain: SwiftTool
}
}
// Update the toolchain path
const isUpdated = await setToolchainPath(selected.swiftFolderPath, developerDir);
let swiftPath: string;

// Handle Swiftly toolchains specially
if (selected.category === "swiftly") {
try {
// Run swiftly use <version> and get the path to the toolchain
await Swiftly.use(selected.label);
swiftPath = Swiftly.getBinDir();
} catch (error) {
void vscode.window.showErrorMessage(`Failed to switch Swiftly toolchain: ${error}`);
return;
}
} else {
// For non-Swiftly toolchains, use the swiftFolderPath
swiftPath = (selected as PublicSwiftToolchainItem | XcodeToolchainItem).swiftFolderPath;
}

const isUpdated = await setToolchainPath(swiftPath, developerDir);
if (isUpdated && selected.onDidSelect) {
await selected.onDidSelect();
}
Expand Down
21 changes: 8 additions & 13 deletions test/unit-tests/toolchain/swiftly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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",
]);
});
Expand Down