diff --git a/src/toolchain/swiftly.ts b/src/toolchain/swiftly.ts index 554811ab8..d7bd97c1f 100644 --- a/src/toolchain/swiftly.ts +++ b/src/toolchain/swiftly.ts @@ -20,6 +20,7 @@ import * as vscode from "vscode"; import { Version } from "../utilities/version"; import { z } from "zod/v4/mini"; import { SwiftLogger } from "../logging/SwiftLogger"; +import { findBinaryPath } from "../utilities/shell"; const ListResult = z.object({ toolchains: z.array( @@ -148,7 +149,7 @@ export class Swiftly { } } - private static isSupported() { + public static isSupported() { return process.platform === "linux" || process.platform === "darwin"; } @@ -235,4 +236,16 @@ export class Swiftly { ); return JSON.parse(swiftlyConfigRaw); } + + public static async isInstalled(): Promise { + if (!this.isSupported()) { + return false; + } + try { + await findBinaryPath("swiftly"); + return true; + } catch (error) { + return false; + } + } } diff --git a/src/toolchain/toolchain.ts b/src/toolchain/toolchain.ts index e96dc2ca1..e50afe8d0 100644 --- a/src/toolchain/toolchain.ts +++ b/src/toolchain/toolchain.ts @@ -26,6 +26,7 @@ import { Sanitizer } from "./Sanitizer"; import { lineBreakRegex } from "../utilities/tasks"; import { Swiftly } from "./swiftly"; import { SwiftLogger } from "../logging/SwiftLogger"; +import { findBinaryPath } from "../utilities/shell"; /** * Contents of **Info.plist** on Windows. */ @@ -547,21 +548,7 @@ export class SwiftToolchain { break; } default: { - // use `type swift` to find `swift`. Run inside /bin/sh to ensure - // we get consistent output as different shells output a different - // format. Tried running with `-p` but that is not available in /bin/sh - const { stdout, stderr } = await execFile("/bin/sh", [ - "-c", - "LC_MESSAGES=C type swift", - ]); - const swiftMatch = /^swift is (.*)$/.exec(stdout.trimEnd()); - if (swiftMatch) { - swift = swiftMatch[1]; - } else { - throw Error( - `/bin/sh -c LC_MESSAGES=C type swift: stdout: ${stdout}, stderr: ${stderr}` - ); - } + swift = await findBinaryPath("swift"); break; } } diff --git a/src/ui/ToolchainSelection.ts b/src/ui/ToolchainSelection.ts index d465ee61e..e42b4f410 100644 --- a/src/ui/ToolchainSelection.ts +++ b/src/ui/ToolchainSelection.ts @@ -259,7 +259,7 @@ async function getQuickPickItems( } // Various actions that the user can perform (e.g. to install new toolchains) const actionItems: ActionItem[] = []; - if (process.platform === "linux" || process.platform === "darwin") { + if (Swiftly.isSupported() && !(await Swiftly.isInstalled())) { const platformName = process.platform === "linux" ? "Linux" : "macOS"; actionItems.push({ type: "action", diff --git a/src/utilities/shell.ts b/src/utilities/shell.ts new file mode 100644 index 000000000..77a6aa295 --- /dev/null +++ b/src/utilities/shell.ts @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// +// 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 { execFile } from "./utilities"; + +// use `type swift` to find `swift`. Run inside /bin/sh to ensure +// we get consistent output as different shells output a different +// format. Tried running with `-p` but that is not available in /bin/sh +export async function findBinaryPath(binaryName: string): Promise { + const { stdout, stderr } = await execFile("/bin/sh", [ + "-c", + `LC_MESSAGES=C type ${binaryName}`, + ]); + const binaryNameMatch = new RegExp(`^${binaryName} is (.*)$`).exec(stdout.trimEnd()); + if (binaryNameMatch) { + return binaryNameMatch[1]; + } else { + throw Error( + `/bin/sh -c LC_MESSAGES=C type ${binaryName}: stdout: ${stdout}, stderr: ${stderr}` + ); + } +} diff --git a/test/unit-tests/toolchain/swiftly.test.ts b/test/unit-tests/toolchain/swiftly.test.ts index fa48bea81..41e11de10 100644 --- a/test/unit-tests/toolchain/swiftly.test.ts +++ b/test/unit-tests/toolchain/swiftly.test.ts @@ -15,10 +15,12 @@ import { expect } from "chai"; import { Swiftly } from "../../../src/toolchain/swiftly"; import * as utilities from "../../../src/utilities/utilities"; +import * as shell from "../../../src/utilities/shell"; import { mockGlobalModule, mockGlobalValue } from "../../MockUtils"; suite("Swiftly Unit Tests", () => { const mockUtilities = mockGlobalModule(utilities); + const mockShell = mockGlobalModule(shell); const mockedPlatform = mockGlobalValue(process, "platform"); setup(() => { @@ -102,4 +104,33 @@ suite("Swiftly Unit Tests", () => { expect(mockUtilities.execFile).not.have.been.called; }); }); + + suite("isInstalled", () => { + test("should return true when swiftly is found", async () => { + mockShell.findBinaryPath.withArgs("swiftly").resolves("/usr/local/bin/swiftly"); + + const result = await Swiftly.isInstalled(); + + expect(result).to.be.true; + expect(mockShell.findBinaryPath).to.have.been.calledWith("swiftly"); + }); + + test("should return false when swiftly is not found", async () => { + mockShell.findBinaryPath.withArgs("swiftly").rejects(new Error("not found")); + + const result = await Swiftly.isInstalled(); + + expect(result).to.be.false; + expect(mockShell.findBinaryPath).to.have.been.calledWith("swiftly"); + }); + + test("should return false when platform is not supported", async () => { + mockedPlatform.setValue("win32"); + + const result = await Swiftly.isInstalled(); + + expect(result).to.be.false; + expect(mockShell.findBinaryPath).not.to.have.been.called; + }); + }); }); diff --git a/test/unit-tests/utilities/shell.test.ts b/test/unit-tests/utilities/shell.test.ts new file mode 100644 index 000000000..cbbd2a5e6 --- /dev/null +++ b/test/unit-tests/utilities/shell.test.ts @@ -0,0 +1,62 @@ +//===----------------------------------------------------------------------===// +// +// 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 { beforeEach, afterEach } from "mocha"; +import { expect } from "chai"; +import * as sinon from "sinon"; +import { findBinaryPath } from "../../../src/utilities/shell"; +import * as utilities from "../../../src/utilities/utilities"; + +suite("Shell Unit Test Suite", () => { + let execFileStub: sinon.SinonStub; + + beforeEach(() => { + execFileStub = sinon.stub(utilities, "execFile"); + }); + + afterEach(() => { + sinon.restore(); + }); + + suite("findBinaryPath", () => { + test("returns the path to a binary in the PATH", async () => { + execFileStub.resolves({ + stdout: "node is /usr/local/bin/node\n", + stderr: "", + }); + + const binaryPath = await findBinaryPath("node"); + expect(binaryPath).to.equal("/usr/local/bin/node"); + expect(execFileStub).to.have.been.calledWith("/bin/sh", [ + "-c", + "LC_MESSAGES=C type node", + ]); + }); + + test("throws for a non-existent binary", async () => { + execFileStub.resolves({ + stdout: "", + stderr: "sh: type: nonexistentbinary: not found\n", + }); + + try { + await findBinaryPath("nonexistentbinary"); + expect.fail("Expected an error to be thrown for a non-existent binary"); + } catch (error) { + expect(error).to.be.an("error"); + expect((error as Error).message).to.include("nonexistentbinary"); + } + }); + }); +});