diff --git a/src/ContextKeyManager.ts b/src/ContextKeyManager.ts new file mode 100644 index 000000000..fce2872f7 --- /dev/null +++ b/src/ContextKeyManager.ts @@ -0,0 +1,372 @@ +//===----------------------------------------------------------------------===// +// +// 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 vscode from "vscode"; + +import { FolderContext } from "./FolderContext"; +import { LanguageClientManager } from "./sourcekit-lsp/LanguageClientManager"; +import { DocCDocumentationRequest, ReIndexProjectRequest } from "./sourcekit-lsp/extensions"; +import { Version } from "./utilities/version"; + +/** + * References: + * + * - `when` clause contexts: + * https://code.visualstudio.com/api/references/when-clause-contexts + */ + +/** Interface for getting and setting the VS Code Swift extension's context keys */ +export interface ContextKeys { + /** + * Whether or not the swift extension is activated. + */ + isActivated: boolean; + + /** + * Whether the workspace folder contains a Swift package. + */ + hasPackage: boolean; + + /** + * Whether the workspace folder contains a Swift package with at least one executable product. + */ + hasExecutableProduct: boolean; + + /** + * Whether the Swift package has any dependencies to display in the Package Dependencies view. + */ + packageHasDependencies: boolean; + + /** + * Whether the dependencies list is displayed in a nested or flat view. + */ + flatDependenciesList: boolean; + + /** + * Whether the Swift package has any plugins. + */ + packageHasPlugins: boolean; + + /** + * Whether current active file is in a SwiftPM source target folder + */ + currentTargetType: string | undefined; + + /** + * Whether current active file is a Snippet + */ + fileIsSnippet: boolean; + + /** + * Whether current active file is a Snippet + */ + lldbVSCodeAvailable: boolean; + + /** + * Whether the swift.createNewProject command is available. + */ + createNewProjectAvailable: boolean; + + /** + * Whether the SourceKit-LSP server supports reindexing the workspace. + */ + supportsReindexing: boolean; + + /** + * Whether the SourceKit-LSP server supports documentation live preview. + */ + supportsDocumentationLivePreview: boolean; + + /** + * Whether the installed version of Swiftly can be used to install toolchains from within VS Code. + */ + supportsSwiftlyInstall: boolean; + + /** + * Whether the swift.switchPlatform command is available. + */ + switchPlatformAvailable: boolean; + + /** + * Sets values for context keys that are enabled/disabled based on the toolchain version in use. + */ + updateKeysBasedOnActiveVersion(toolchainVersion: Version): void; + + /** + * Update context keys based on package contents. Call this when folder focus changes. + */ + updateForFolder(folderContext: FolderContext | null): void; + + /** + * Update context keys based on current file. Call this when the active file changes. + */ + updateForFile( + currentDocument: vscode.Uri | null, + currentFolder: FolderContext | null, + languageClientManager: { get(folder: FolderContext): LanguageClientManager } + ): Promise; + + /** + * Update hasPlugins context key by checking all folders. Call this when packages are added/removed or plugins change. + */ + updateForPlugins(folders: FolderContext[]): void; +} + +/** + * Manages the extension's context key values. + */ +export class ContextKeyManager implements ContextKeys { + private _isActivated = false; + private _hasPackage = false; + private _hasExecutableProduct = false; + private _flatDependenciesList = false; + private _packageHasDependencies = false; + private _packageHasPlugins = false; + private _currentTargetType: string | undefined = undefined; + private _fileIsSnippet = false; + private _lldbVSCodeAvailable = false; + private _createNewProjectAvailable = false; + private _supportsReindexing = false; + private _supportsDocumentationLivePreview = false; + private _supportsSwiftlyInstall = false; + private _switchPlatformAvailable = false; + + get isActivated(): boolean { + return this._isActivated; + } + set isActivated(value: boolean) { + this._isActivated = value; + void vscode.commands.executeCommand("setContext", "swift.isActivated", value); + } + + get hasPackage(): boolean { + return this._hasPackage; + } + set hasPackage(value: boolean) { + this._hasPackage = value; + void vscode.commands.executeCommand("setContext", "swift.hasPackage", value); + } + + get hasExecutableProduct(): boolean { + return this._hasExecutableProduct; + } + set hasExecutableProduct(value: boolean) { + this._hasExecutableProduct = value; + void vscode.commands.executeCommand("setContext", "swift.hasExecutableProduct", value); + } + + get packageHasDependencies(): boolean { + return this._packageHasDependencies; + } + set packageHasDependencies(value: boolean) { + this._packageHasDependencies = value; + void vscode.commands.executeCommand("setContext", "swift.packageHasDependencies", value); + } + + get flatDependenciesList(): boolean { + return this._flatDependenciesList; + } + set flatDependenciesList(value: boolean) { + this._flatDependenciesList = value; + void vscode.commands.executeCommand("setContext", "swift.flatDependenciesList", value); + } + + get packageHasPlugins(): boolean { + return this._packageHasPlugins; + } + set packageHasPlugins(value: boolean) { + this._packageHasPlugins = value; + void vscode.commands.executeCommand("setContext", "swift.packageHasPlugins", value); + } + + get currentTargetType(): string | undefined { + return this._currentTargetType; + } + set currentTargetType(value: string | undefined) { + this._currentTargetType = value; + void vscode.commands.executeCommand( + "setContext", + "swift.currentTargetType", + value ?? "none" + ); + } + + get fileIsSnippet(): boolean { + return this._fileIsSnippet; + } + set fileIsSnippet(value: boolean) { + this._fileIsSnippet = value; + void vscode.commands.executeCommand("setContext", "swift.fileIsSnippet", value); + } + + get lldbVSCodeAvailable(): boolean { + return this._lldbVSCodeAvailable; + } + set lldbVSCodeAvailable(value: boolean) { + this._lldbVSCodeAvailable = value; + void vscode.commands.executeCommand("setContext", "swift.lldbVSCodeAvailable", value); + } + + get createNewProjectAvailable(): boolean { + return this._createNewProjectAvailable; + } + set createNewProjectAvailable(value: boolean) { + this._createNewProjectAvailable = value; + void vscode.commands.executeCommand("setContext", "swift.createNewProjectAvailable", value); + } + + get supportsReindexing(): boolean { + return this._supportsReindexing; + } + set supportsReindexing(value: boolean) { + this._supportsReindexing = value; + void vscode.commands.executeCommand("setContext", "swift.supportsReindexing", value); + } + + get supportsDocumentationLivePreview(): boolean { + return this._supportsDocumentationLivePreview; + } + set supportsDocumentationLivePreview(value: boolean) { + this._supportsDocumentationLivePreview = value; + void vscode.commands.executeCommand( + "setContext", + "swift.supportsDocumentationLivePreview", + value + ); + } + + get supportsSwiftlyInstall(): boolean { + return this._supportsSwiftlyInstall; + } + set supportsSwiftlyInstall(value: boolean) { + this._supportsSwiftlyInstall = value; + void vscode.commands.executeCommand("setContext", "swift.supportsSwiftlyInstall", value); + } + + get switchPlatformAvailable(): boolean { + return this._switchPlatformAvailable; + } + set switchPlatformAvailable(value: boolean) { + this._switchPlatformAvailable = value; + void vscode.commands.executeCommand("setContext", "swift.switchPlatformAvailable", value); + } + + /** + * Update context keys based on package contents. + * Call this when folder focus changes. + */ + updateForFolder(folderContext: FolderContext | null): void { + if (!folderContext) { + this.hasPackage = false; + this.hasExecutableProduct = false; + this.packageHasDependencies = false; + return; + } + + void Promise.all([ + folderContext.swiftPackage.foundPackage, + folderContext.swiftPackage.executableProducts, + folderContext.swiftPackage.dependencies, + ]).then(([foundPackage, executableProducts, dependencies]) => { + this.hasPackage = foundPackage; + this.hasExecutableProduct = executableProducts.length > 0; + this.packageHasDependencies = dependencies.length > 0; + }); + } + + /** + * Update context keys based on current file. + * Call this when the active file changes. + */ + async updateForFile( + currentDocument: vscode.Uri | null, + currentFolder: FolderContext | null, + languageClientManager: { get(folder: FolderContext): LanguageClientManager } + ): Promise { + if (currentDocument && currentFolder) { + const target = await currentFolder.swiftPackage.getTarget(currentDocument.fsPath); + this.currentTargetType = target?.type; + } else { + this.currentTargetType = undefined; + } + + if (currentFolder) { + const languageClient = languageClientManager.get(currentFolder); + await languageClient.useLanguageClient(async client => { + const experimentalCaps = client.initializeResult?.capabilities.experimental; + if (!experimentalCaps) { + this.supportsReindexing = false; + this.supportsDocumentationLivePreview = false; + return; + } + this.supportsReindexing = + experimentalCaps[ReIndexProjectRequest.method] !== undefined; + this.supportsDocumentationLivePreview = + experimentalCaps[DocCDocumentationRequest.method] !== undefined; + }); + } + + this.updateSnippetContextKey(currentDocument, currentFolder); + } + + /** + * Update hasPlugins context key by checking all folders. + * Call this when packages are added/removed or plugins change. + */ + updateForPlugins(folders: FolderContext[]): void { + let hasPlugins = false; + for (const folder of folders) { + if (folder.swiftPackage.plugins.length > 0) { + hasPlugins = true; + break; + } + } + this.packageHasPlugins = hasPlugins; + } + + /** + * Update fileIsSnippet context key based on current file location. + * Private helper called from updateForFile. + */ + private updateSnippetContextKey( + currentDocument: vscode.Uri | null, + currentFolder: FolderContext | null + ): void { + if ( + !currentFolder || + !currentDocument || + currentFolder.swiftVersion.isLessThan({ major: 5, minor: 7, patch: 0 }) + ) { + this.fileIsSnippet = false; + return; + } + + const filename = currentDocument.fsPath; + const snippetsFolder = path.join(currentFolder.folder.fsPath, "Snippets"); + this.fileIsSnippet = filename.startsWith(snippetsFolder); + } + + /** + * Sets values for context keys that are enabled/disabled based on the toolchain version in use. + */ + updateKeysBasedOnActiveVersion(toolchainVersion: Version): void { + this.createNewProjectAvailable = toolchainVersion.isGreaterThanOrEqual( + new Version(5, 8, 0) + ); + this.switchPlatformAvailable = + process.platform === "darwin" + ? toolchainVersion.isGreaterThanOrEqual(new Version(6, 1, 0)) + : false; + } +} diff --git a/src/WorkspaceContext.ts b/src/WorkspaceContext.ts index c679b6c00..a63f36bee 100644 --- a/src/WorkspaceContext.ts +++ b/src/WorkspaceContext.ts @@ -14,13 +14,12 @@ import * as path from "path"; import * as vscode from "vscode"; +import { ContextKeys } from "./ContextKeyManager"; import { DiagnosticsManager } from "./DiagnosticsManager"; import { FolderContext } from "./FolderContext"; -import { setSnippetContextKey } from "./SwiftSnippets"; import { TestKind } from "./TestExplorer/TestKind"; import { TestRunManager } from "./TestExplorer/TestRunManager"; import configuration from "./configuration"; -import { ContextKeys } from "./contextKeys"; import { LLDBDebugConfigurationProvider } from "./debugger/debugAdapterFactory"; import { makeDebugConfigurations } from "./debugger/launch"; import { DocumentationManager } from "./documentation/DocumentationManager"; @@ -29,7 +28,6 @@ import { SwiftLogger } from "./logging/SwiftLogger"; import { SwiftLoggerFactory } from "./logging/SwiftLoggerFactory"; import { LanguageClientToolchainCoordinator } from "./sourcekit-lsp/LanguageClientToolchainCoordinator"; import { SourcekitSchemaRegistry } from "./sourcekit-lsp/SourcekitSchemaRegistry"; -import { DocCDocumentationRequest, ReIndexProjectRequest } from "./sourcekit-lsp/extensions"; import { SwiftPluginTaskProvider } from "./tasks/SwiftPluginTaskProvider"; import { SwiftTaskProvider } from "./tasks/SwiftTaskProvider"; import { TaskManager } from "./tasks/TaskManager"; @@ -172,18 +170,22 @@ export class WorkspaceContext implements vscode.Disposable { const contextKeysUpdate = this.onDidChangeFolders(event => { switch (event.operation) { case FolderOperation.remove: - this.updatePluginContextKey(); + this.contextKeys.updateForPlugins(this.folders); break; case FolderOperation.focus: - this.updateContextKeys(event.folder); - void this.updateContextKeysForFile(); + this.contextKeys.updateForFolder(event.folder); + void this.contextKeys.updateForFile( + this.currentDocument, + event.folder, + this.languageClientManager + ); break; case FolderOperation.unfocus: - this.updateContextKeys(event.folder); + this.contextKeys.updateForFolder(event.folder); break; case FolderOperation.resolvedUpdated: if (event.folder === this.currentFolder) { - this.updateContextKeys(event.folder); + this.contextKeys.updateForFolder(event.folder); } } }); @@ -262,74 +264,6 @@ export class WorkspaceContext implements vscode.Disposable { return this.globalToolchain.swiftVersion; } - /** - * Update context keys based on package contents - */ - updateContextKeys(folderContext: FolderContext | null) { - if (!folderContext) { - this.contextKeys.hasPackage = false; - this.contextKeys.hasExecutableProduct = false; - this.contextKeys.packageHasDependencies = false; - return; - } - - void Promise.all([ - folderContext.swiftPackage.foundPackage, - folderContext.swiftPackage.executableProducts, - folderContext.swiftPackage.dependencies, - ]).then(([foundPackage, executableProducts, dependencies]) => { - this.contextKeys.hasPackage = foundPackage; - this.contextKeys.hasExecutableProduct = executableProducts.length > 0; - this.contextKeys.packageHasDependencies = dependencies.length > 0; - }); - } - - /** - * Update context keys based on package contents - */ - async updateContextKeysForFile() { - if (this.currentDocument) { - const target = await this.currentFolder?.swiftPackage.getTarget( - this.currentDocument?.fsPath - ); - this.contextKeys.currentTargetType = target?.type; - } else { - this.contextKeys.currentTargetType = undefined; - } - - if (this.currentFolder) { - const languageClient = this.languageClientManager.get(this.currentFolder); - await languageClient.useLanguageClient(async client => { - const experimentalCaps = client.initializeResult?.capabilities.experimental; - if (!experimentalCaps) { - this.contextKeys.supportsReindexing = false; - this.contextKeys.supportsDocumentationLivePreview = false; - return; - } - this.contextKeys.supportsReindexing = - experimentalCaps[ReIndexProjectRequest.method] !== undefined; - this.contextKeys.supportsDocumentationLivePreview = - experimentalCaps[DocCDocumentationRequest.method] !== undefined; - }); - } - - setSnippetContextKey(this); - } - - /** - * Update hasPlugins context key - */ - updatePluginContextKey() { - let hasPlugins = false; - for (const folder of this.folders) { - if (folder.swiftPackage.plugins.length > 0) { - hasPlugins = true; - break; - } - } - this.contextKeys.packageHasPlugins = hasPlugins; - } - /** Setup the vscode event listeners to catch folder changes and active window changes */ private setupEventListeners() { // add event listener for when a workspace folder is added/removed @@ -569,7 +503,11 @@ export class WorkspaceContext implements vscode.Disposable { async focusUri(uri?: vscode.Uri) { this.currentDocument = uri ?? null; - await this.updateContextKeysForFile(); + await this.contextKeys.updateForFile( + this.currentDocument, + this.currentFolder ?? null, + this.languageClientManager + ); if ( this.currentDocument?.scheme === "file" || this.currentDocument?.scheme === "sourcekit-lsp" diff --git a/src/contextKeys.ts b/src/contextKeys.ts deleted file mode 100644 index 97524fce1..000000000 --- a/src/contextKeys.ts +++ /dev/null @@ -1,313 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the VS Code Swift open source project -// -// Copyright (c) 2021 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 vscode from "vscode"; - -import { Version } from "./utilities/version"; - -/** - * References: - * - * - `when` clause contexts: - * https://code.visualstudio.com/api/references/when-clause-contexts - */ - -/** Interface for getting and setting the VS Code Swift extension's context keys */ -export interface ContextKeys { - /** - * Whether or not the swift extension is activated. - */ - isActivated: boolean; - - /** - * Whether the workspace folder contains a Swift package. - */ - hasPackage: boolean; - - /** - * Whether the workspace folder contains a Swift package with at least one executable product. - */ - hasExecutableProduct: boolean; - - /** - * Whether the Swift package has any dependencies to display in the Package Dependencies view. - */ - packageHasDependencies: boolean; - - /** - * Whether the dependencies list is displayed in a nested or flat view. - */ - flatDependenciesList: boolean; - - /** - * Whether the Swift package has any plugins. - */ - packageHasPlugins: boolean; - - /** - * Whether current active file is in a SwiftPM source target folder - */ - currentTargetType: string | undefined; - - /** - * Whether current active file is a Snippet - */ - fileIsSnippet: boolean; - - /** - * Whether current active file is a Snippet - */ - lldbVSCodeAvailable: boolean; - - /** - * Whether the swift.createNewProject command is available. - */ - createNewProjectAvailable: boolean; - - /** - * Whether the SourceKit-LSP server supports reindexing the workspace. - */ - supportsReindexing: boolean; - - /** - * Whether the SourceKit-LSP server supports documentation live preview. - */ - supportsDocumentationLivePreview: boolean; - - /** - * Whether the installed version of Swiftly can be used to install toolchains from within VS Code. - */ - supportsSwiftlyInstall: boolean; - - /** - * Whether the swift.switchPlatform command is available. - */ - switchPlatformAvailable: boolean; - - /** - * Sets values for context keys that are enabled/disabled based on the toolchain version in use. - */ - updateKeysBasedOnActiveVersion(toolchainVersion: Version): void; -} - -/** Creates the getters and setters for the VS Code Swift extension's context keys. */ -export function createContextKeys(): ContextKeys { - let isActivated: boolean = false; - let hasPackage: boolean = false; - let hasExecutableProduct: boolean = false; - let flatDependenciesList: boolean = false; - let packageHasDependencies: boolean = false; - let packageHasPlugins: boolean = false; - let currentTargetType: string | undefined = undefined; - let fileIsSnippet: boolean = false; - let lldbVSCodeAvailable: boolean = false; - let createNewProjectAvailable: boolean = false; - let supportsReindexing: boolean = false; - let supportsDocumentationLivePreview: boolean = false; - let supportsSwiftlyInstall: boolean = false; - let switchPlatformAvailable: boolean = false; - - return { - updateKeysBasedOnActiveVersion(toolchainVersion: Version) { - this.createNewProjectAvailable = toolchainVersion.isGreaterThanOrEqual( - new Version(5, 8, 0) - ); - this.switchPlatformAvailable = - process.platform === "darwin" - ? toolchainVersion.isGreaterThanOrEqual(new Version(6, 1, 0)) - : false; - }, - - get isActivated() { - return isActivated; - }, - - set isActivated(value: boolean) { - isActivated = value; - void vscode.commands - .executeCommand("setContext", "swift.isActivated", value) - .then(() => { - /* Put in worker queue */ - }); - }, - - get hasPackage() { - return hasPackage; - }, - - set hasPackage(value: boolean) { - hasPackage = value; - void vscode.commands - .executeCommand("setContext", "swift.hasPackage", value) - .then(() => { - /* Put in worker queue */ - }); - }, - - get hasExecutableProduct() { - return hasExecutableProduct; - }, - - set hasExecutableProduct(value: boolean) { - hasExecutableProduct = value; - void vscode.commands - .executeCommand("setContext", "swift.hasExecutableProduct", value) - .then(() => { - /* Put in worker queue */ - }); - }, - - get packageHasDependencies() { - return packageHasDependencies; - }, - - set packageHasDependencies(value: boolean) { - packageHasDependencies = value; - void vscode.commands - .executeCommand("setContext", "swift.packageHasDependencies", value) - .then(() => { - /* Put in worker queue */ - }); - }, - - get flatDependenciesList() { - return flatDependenciesList; - }, - - set flatDependenciesList(value: boolean) { - flatDependenciesList = value; - void vscode.commands - .executeCommand("setContext", "swift.flatDependenciesList", value) - .then(() => { - /* Put in worker queue */ - }); - }, - - get packageHasPlugins() { - return packageHasPlugins; - }, - - set packageHasPlugins(value: boolean) { - packageHasPlugins = value; - void vscode.commands - .executeCommand("setContext", "swift.packageHasPlugins", value) - .then(() => { - /* Put in worker queue */ - }); - }, - - get currentTargetType() { - return currentTargetType; - }, - - set currentTargetType(value: string | undefined) { - currentTargetType = value; - void vscode.commands - .executeCommand("setContext", "swift.currentTargetType", value ?? "none") - .then(() => { - /* Put in worker queue */ - }); - }, - - get fileIsSnippet() { - return fileIsSnippet; - }, - - set fileIsSnippet(value: boolean) { - fileIsSnippet = value; - void vscode.commands - .executeCommand("setContext", "swift.fileIsSnippet", value) - .then(() => { - /* Put in worker queue */ - }); - }, - - get lldbVSCodeAvailable() { - return lldbVSCodeAvailable; - }, - - set lldbVSCodeAvailable(value: boolean) { - lldbVSCodeAvailable = value; - void vscode.commands - .executeCommand("setContext", "swift.lldbVSCodeAvailable", value) - .then(() => { - /* Put in worker queue */ - }); - }, - - get createNewProjectAvailable() { - return createNewProjectAvailable; - }, - - set createNewProjectAvailable(value: boolean) { - createNewProjectAvailable = value; - void vscode.commands - .executeCommand("setContext", "swift.createNewProjectAvailable", value) - .then(() => { - /* Put in worker queue */ - }); - }, - - get supportsReindexing() { - return supportsReindexing; - }, - - set supportsReindexing(value: boolean) { - supportsReindexing = value; - void vscode.commands - .executeCommand("setContext", "swift.supportsReindexing", value) - .then(() => { - /* Put in worker queue */ - }); - }, - - get supportsDocumentationLivePreview() { - return supportsDocumentationLivePreview; - }, - - set supportsDocumentationLivePreview(value: boolean) { - supportsDocumentationLivePreview = value; - void vscode.commands - .executeCommand("setContext", "swift.supportsDocumentationLivePreview", value) - .then(() => { - /* 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 */ - }); - }, - - get switchPlatformAvailable() { - return switchPlatformAvailable; - }, - - set switchPlatformAvailable(value: boolean) { - switchPlatformAvailable = value; - void vscode.commands - .executeCommand("setContext", "swift.switchPlatformAvailable", value) - .then(() => { - /* Put in worker queue */ - }); - }, - }; -} diff --git a/src/extension.ts b/src/extension.ts index ab9760e81..367b753ae 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -16,6 +16,7 @@ import "source-map-support/register"; import * as vscode from "vscode"; +import { ContextKeyManager, ContextKeys } from "./ContextKeyManager"; import { FolderContext } from "./FolderContext"; import { TestExplorer } from "./TestExplorer/TestExplorer"; import { FolderEvent, FolderOperation, WorkspaceContext } from "./WorkspaceContext"; @@ -23,7 +24,6 @@ import * as commands from "./commands"; import { resolveFolderDependencies } from "./commands/dependencies/resolve"; import { registerSourceKitSchemaWatcher } from "./commands/generateSourcekitConfiguration"; import configuration, { handleConfigurationChangeEvent } from "./configuration"; -import { ContextKeys, createContextKeys } from "./contextKeys"; import { registerDebugger } from "./debugger/debugAdapterFactory"; import * as debug from "./debugger/launch"; import { SwiftLogger } from "./logging/SwiftLogger"; @@ -68,7 +68,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { const preToolchainStartTime = Date.now(); checkAndWarnAboutWindowsSymlinks(logger); - const contextKeys = createContextKeys(); + const contextKeys = new ContextKeyManager(); const preToolchainElapsed = Date.now() - preToolchainStartTime; const toolchainStartTime = Date.now(); const toolchain = await createActiveToolchain(context, contextKeys, logger); @@ -243,7 +243,7 @@ function handleFolderEvent(logger: SwiftLogger): (event: FolderEvent) => Promise `Loading Swift Plugins (${FolderContext.uriName(folder.workspaceFolder.uri)})`, async () => { await folder.loadSwiftPlugins(logger); - workspace.updatePluginContextKey(); + workspace.contextKeys.updateForPlugins(workspace.folders); await folder.fireEvent(FolderOperation.pluginsUpdated); } ); diff --git a/src/toolchain/swiftly.ts b/src/toolchain/swiftly.ts index fae3fd6d2..28a7e95de 100644 --- a/src/toolchain/swiftly.ts +++ b/src/toolchain/swiftly.ts @@ -21,9 +21,9 @@ import * as Stream from "stream"; import * as vscode from "vscode"; import { z } from "zod/v4/mini"; +import { ContextKeys } from "../ContextKeyManager"; import { withAskpassServer } from "../askpass/askpass-server"; import { installSwiftlyToolchainWithProgress } from "../commands/installSwiftlyToolchain"; -import { ContextKeys } from "../contextKeys"; import { SwiftLogger } from "../logging/SwiftLogger"; import { showMissingToolchainDialog } from "../ui/ToolchainSelection"; import { touch } from "../utilities/filesystem"; diff --git a/test/unit-tests/ContextKeyManager.test.ts b/test/unit-tests/ContextKeyManager.test.ts new file mode 100644 index 000000000..f8a1064d8 --- /dev/null +++ b/test/unit-tests/ContextKeyManager.test.ts @@ -0,0 +1,526 @@ +//===----------------------------------------------------------------------===// +// +// 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 { stub } from "sinon"; +import * as vscode from "vscode"; + +import { ContextKeyManager } from "@src/ContextKeyManager"; +import { FolderContext } from "@src/FolderContext"; +import { SwiftPackage } from "@src/SwiftPackage"; +import { LanguageClientManager } from "@src/sourcekit-lsp/LanguageClientManager"; +import { Version } from "@src/utilities/version"; + +import { instance, mockObject } from "../MockUtils"; + +suite("ContextKeyManager Suite", () => { + let manager: ContextKeyManager; + let executeCommandStub: any; + + setup(() => { + // Stub vscode.commands.executeCommand to prevent actual VS Code command execution + executeCommandStub = stub(vscode.commands, "executeCommand"); + manager = new ContextKeyManager(); + }); + + teardown(() => { + executeCommandStub.restore(); + }); + + suite("Property Setters", () => { + test("all boolean properties set value and call setContext", () => { + const properties: Array<{ + key: keyof ContextKeyManager; + contextKey: string; + value: boolean; + }> = [ + { key: "isActivated", contextKey: "swift.isActivated", value: true }, + { key: "hasPackage", contextKey: "swift.hasPackage", value: true }, + { + key: "hasExecutableProduct", + contextKey: "swift.hasExecutableProduct", + value: true, + }, + { + key: "packageHasDependencies", + contextKey: "swift.packageHasDependencies", + value: true, + }, + { + key: "flatDependenciesList", + contextKey: "swift.flatDependenciesList", + value: true, + }, + { key: "packageHasPlugins", contextKey: "swift.packageHasPlugins", value: true }, + { key: "fileIsSnippet", contextKey: "swift.fileIsSnippet", value: true }, + { + key: "lldbVSCodeAvailable", + contextKey: "swift.lldbVSCodeAvailable", + value: true, + }, + { + key: "createNewProjectAvailable", + contextKey: "swift.createNewProjectAvailable", + value: true, + }, + { key: "supportsReindexing", contextKey: "swift.supportsReindexing", value: true }, + { + key: "supportsDocumentationLivePreview", + contextKey: "swift.supportsDocumentationLivePreview", + value: true, + }, + { + key: "supportsSwiftlyInstall", + contextKey: "swift.supportsSwiftlyInstall", + value: true, + }, + { + key: "switchPlatformAvailable", + contextKey: "swift.switchPlatformAvailable", + value: true, + }, + ]; + + properties.forEach(({ key, contextKey, value }) => { + executeCommandStub.resetHistory(); + (manager as any)[key] = value; + + expect(manager[key]).to.equal(value, `Property ${key} should be set to ${value}`); + expect( + executeCommandStub.calledWith("setContext", contextKey, value), + `setContext should be called for ${contextKey}` + ).to.be.true; + }); + }); + + test("currentTargetType sets value and calls setContext with 'none' for undefined", () => { + manager.currentTargetType = undefined; + + expect(manager.currentTargetType).to.be.undefined; + expect(executeCommandStub.calledWith("setContext", "swift.currentTargetType", "none")) + .to.be.true; + }); + + test("currentTargetType sets value and calls setContext with actual value", () => { + manager.currentTargetType = "executable"; + + expect(manager.currentTargetType).to.equal("executable"); + expect( + executeCommandStub.calledWith("setContext", "swift.currentTargetType", "executable") + ).to.be.true; + }); + }); + + suite("updateForFolder", () => { + test("resets package keys when folder is null", () => { + manager.hasPackage = true; + manager.hasExecutableProduct = true; + manager.packageHasDependencies = true; + + manager.updateForFolder(null); + + expect(manager.hasPackage).to.be.false; + expect(manager.hasExecutableProduct).to.be.false; + expect(manager.packageHasDependencies).to.be.false; + }); + + test("updates package keys from folder context", async () => { + const mockFolder = mockObject({ + swiftPackage: instance( + mockObject({ + foundPackage: Promise.resolve(true), + executableProducts: Promise.resolve([ + { name: "test", type: { executable: null }, targets: [] }, + ]), + dependencies: Promise.resolve([{ identity: "dep1", dependencies: [] }]), + }) + ), + }); + + manager.updateForFolder(instance(mockFolder)); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(manager.hasPackage).to.be.true; + expect(manager.hasExecutableProduct).to.be.true; + expect(manager.packageHasDependencies).to.be.true; + }); + + test("handles no executable products", async () => { + const mockFolder = mockObject({ + swiftPackage: instance( + mockObject({ + foundPackage: Promise.resolve(true), + executableProducts: Promise.resolve([]), + dependencies: Promise.resolve([]), + }) + ), + }); + + manager.updateForFolder(instance(mockFolder)); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(manager.hasPackage).to.be.true; + expect(manager.hasExecutableProduct).to.be.false; + expect(manager.packageHasDependencies).to.be.false; + }); + }); + + suite("updateForFile", () => { + test("resets currentTargetType when document is null", async () => { + manager.currentTargetType = "executable"; + + await manager.updateForFile( + null, + null, + instance( + mockObject<{ get(folder: FolderContext): LanguageClientManager }>({ + get: () => ({}) as LanguageClientManager, + }) + ) + ); + + expect(manager.currentTargetType).to.be.undefined; + }); + + test("resets currentTargetType when folder is null", async () => { + manager.currentTargetType = "executable"; + const mockUri = vscode.Uri.file("/test/file.swift"); + + await manager.updateForFile( + mockUri, + null, + instance( + mockObject<{ get(folder: FolderContext): LanguageClientManager }>({ + get: () => ({}) as LanguageClientManager, + }) + ) + ); + + expect(manager.currentTargetType).to.be.undefined; + }); + + test("updates currentTargetType from folder context", async () => { + const mockUri = vscode.Uri.file("/test/file.swift"); + const mockSwiftPackage = mockObject({ + getTarget: async () => ({ + type: "executable", + name: "test", + c99name: "test", + path: "/test", + sources: [], + }), + }); + mockSwiftPackage.getTarget.resolves({ + type: "executable", + name: "test", + c99name: "test", + path: "/test", + sources: [], + }); + const mockFolder = mockObject({ + swiftPackage: instance(mockSwiftPackage), + swiftVersion: new Version(5, 7, 0), + folder: mockUri, + }); + const mockLanguageClient = mockObject({ + useLanguageClient: async (_fn: any): Promise => { + return undefined as Return; + }, + }); + const mockLanguageClientManager = mockObject<{ + get(folder: FolderContext): LanguageClientManager; + }>({ + get: () => instance(mockLanguageClient), + }); + mockLanguageClientManager.get.returns(instance(mockLanguageClient)); + + await manager.updateForFile( + mockUri, + instance(mockFolder), + instance(mockLanguageClientManager) + ); + + expect(manager.currentTargetType).to.equal("executable"); + }); + + test("updates SourceKit-LSP capabilities", async () => { + const mockUri = vscode.Uri.file("/test/file.swift"); + const mockFolder = mockObject({ + swiftPackage: instance( + mockObject({ + getTarget: async () => undefined, + }) + ), + swiftVersion: new Version(5, 7, 0), + folder: mockUri, + }); + const mockLanguageClient = { + useLanguageClient: async (fn: any) => { + await fn({ + initializeResult: { + capabilities: { + experimental: { + "workspace/triggerReindex": {}, + "textDocument/doccDocumentation": {}, + }, + }, + }, + }); + }, + }; + const mockLanguageClientManager = { + get: () => mockLanguageClient, + }; + + await manager.updateForFile( + mockUri, + instance(mockFolder), + instance(mockLanguageClientManager) + ); + + expect(manager.supportsReindexing).to.be.true; + expect(manager.supportsDocumentationLivePreview).to.be.true; + }); + + test("resets SourceKit-LSP capabilities when not available", async () => { + manager.supportsReindexing = true; + manager.supportsDocumentationLivePreview = true; + + const mockUri = vscode.Uri.file("/test/file.swift"); + const mockFolder = mockObject({ + swiftPackage: instance( + mockObject({ + getTarget: async () => undefined, + }) + ), + swiftVersion: new Version(5, 7, 0), + folder: mockUri, + }); + const mockLanguageClient = { + useLanguageClient: async (fn: any) => { + await fn({ + initializeResult: { + capabilities: {}, + }, + }); + }, + }; + const mockLanguageClientManager = { + get: () => mockLanguageClient, + }; + + await manager.updateForFile( + mockUri, + instance(mockFolder), + instance(mockLanguageClientManager) + ); + + expect(manager.supportsReindexing).to.be.false; + expect(manager.supportsDocumentationLivePreview).to.be.false; + }); + + test("sets fileIsSnippet when file is in Snippets folder", async () => { + const mockUri = vscode.Uri.file("/test/Snippets/MySnippet.swift"); + const mockFolderUri = vscode.Uri.file("/test"); + const mockFolder = mockObject({ + swiftPackage: instance( + mockObject({ + getTarget: async () => undefined, + }) + ), + swiftVersion: new Version(5, 7, 0), + folder: mockFolderUri, + }); + const mockLanguageClient = mockObject({ + useLanguageClient: async (_fn: any): Promise => { + return undefined as Return; + }, + }); + const mockLanguageClientManager = mockObject<{ + get(folder: FolderContext): LanguageClientManager; + }>({ + get: () => instance(mockLanguageClient), + }); + mockLanguageClientManager.get.returns(instance(mockLanguageClient)); + + await manager.updateForFile( + mockUri, + instance(mockFolder), + instance(mockLanguageClientManager) + ); + + expect(manager.fileIsSnippet).to.be.true; + }); + + test("does not set fileIsSnippet for Swift version < 5.7", async () => { + const mockUri = vscode.Uri.file("/test/Snippets/MySnippet.swift"); + const mockFolderUri = vscode.Uri.file("/test"); + const mockFolder = mockObject({ + swiftPackage: instance( + mockObject({ + getTarget: async () => undefined, + }) + ), + swiftVersion: new Version(5, 6, 0), + folder: mockFolderUri, + }); + const mockLanguageClient = { + useLanguageClient: async (_fn: any) => {}, + }; + const mockLanguageClientManager = { + get: () => mockLanguageClient, + }; + + await manager.updateForFile( + mockUri, + instance(mockFolder), + instance(mockLanguageClientManager) + ); + + expect(manager.fileIsSnippet).to.be.false; + }); + + test("clears fileIsSnippet when file is not in Snippets folder", async () => { + manager.fileIsSnippet = true; + + const mockUri = vscode.Uri.file("/test/Sources/MyFile.swift"); + const mockFolderUri = vscode.Uri.file("/test"); + const mockFolder = mockObject({ + swiftPackage: instance( + mockObject({ + getTarget: async () => undefined, + }) + ), + swiftVersion: new Version(5, 7, 0), + folder: mockFolderUri, + }); + const mockLanguageClient = mockObject({ + useLanguageClient: async (_fn: any): Promise => { + return undefined as Return; + }, + }); + const mockLanguageClientManager = mockObject<{ + get(folder: FolderContext): LanguageClientManager; + }>({ + get: () => instance(mockLanguageClient), + }); + mockLanguageClientManager.get.returns(instance(mockLanguageClient)); + + await manager.updateForFile( + mockUri, + instance(mockFolder), + instance(mockLanguageClientManager) + ); + + expect(manager.fileIsSnippet).to.be.false; + }); + }); + + suite("updateForPlugins", () => { + test("sets packageHasPlugins to true when any folder has plugins", () => { + const mockFolders = [ + mockObject({ + swiftPackage: instance(mockObject({ plugins: [] })), + }), + mockObject({ + swiftPackage: instance( + mockObject({ + plugins: [{ name: "test-plugin", command: "cmd", package: "pkg" }], + }) + ), + }), + ]; + + manager.updateForPlugins([instance(mockFolders[0]), instance(mockFolders[1])]); + + expect(manager.packageHasPlugins).to.be.true; + }); + + test("sets packageHasPlugins to false when no folders have plugins", () => { + manager.packageHasPlugins = true; + + const mockFolders = [ + mockObject({ + swiftPackage: instance(mockObject({ plugins: [] })), + }), + mockObject({ + swiftPackage: instance(mockObject({ plugins: [] })), + }), + ]; + + manager.updateForPlugins([instance(mockFolders[0]), instance(mockFolders[1])]); + + expect(manager.packageHasPlugins).to.be.false; + }); + + test("sets packageHasPlugins to false for empty folder array", () => { + manager.packageHasPlugins = true; + + manager.updateForPlugins([]); + + expect(manager.packageHasPlugins).to.be.false; + }); + }); + + suite("updateKeysBasedOnActiveVersion", () => { + test("enables createNewProjectAvailable for Swift 5.8.0+", () => { + manager.updateKeysBasedOnActiveVersion(new Version(5, 8, 0)); + + expect(manager.createNewProjectAvailable).to.be.true; + }); + + test("disables createNewProjectAvailable for Swift < 5.8.0", () => { + manager.createNewProjectAvailable = true; + + manager.updateKeysBasedOnActiveVersion(new Version(5, 7, 0)); + + expect(manager.createNewProjectAvailable).to.be.false; + }); + + test("enables switchPlatformAvailable on macOS for Swift 6.1.0+", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "darwin" }); + + manager.updateKeysBasedOnActiveVersion(new Version(6, 1, 0)); + + expect(manager.switchPlatformAvailable).to.be.true; + + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + + test("disables switchPlatformAvailable on non-macOS platforms", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "linux" }); + + manager.updateKeysBasedOnActiveVersion(new Version(6, 1, 0)); + + expect(manager.switchPlatformAvailable).to.be.false; + + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + + test("disables switchPlatformAvailable on macOS for Swift < 6.1.0", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "darwin" }); + manager.switchPlatformAvailable = true; + + manager.updateKeysBasedOnActiveVersion(new Version(6, 0, 0)); + + expect(manager.switchPlatformAvailable).to.be.false; + + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + }); +});