From 5fbd912abdb690577375ea0ec2d37f1c31ae6510 Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Fri, 5 Apr 2024 15:03:06 +0200 Subject: [PATCH] Add `codeQL.model.packLocation` setting This adds the `codeQL.model.packLocation` setting, which allows users to specify the location of the CodeQL extension pack. This setting replaces the `codeQL.model.extensionsDirectory` setting, which has been removed. The pack location supports variable substitutions and supports both absolute and relative paths. The default value is `.github/codeql/extensions/${name}-${language}` which matches the previous defaults. --- extensions/ql-vscode/package.json | 16 +- extensions/ql-vscode/src/config.ts | 29 +- .../src/model-editor/extension-pack-picker.ts | 54 ++- .../extensions-workspace-folder.ts | 246 ++++++++---- .../extension-pack-picker.test.ts | 187 ++++++++- .../extensions-workspace-folder.test.ts | 373 +++++++++++++----- 6 files changed, 684 insertions(+), 221 deletions(-) diff --git a/extensions/ql-vscode/package.json b/extensions/ql-vscode/package.json index 63bab9219..943efec90 100644 --- a/extensions/ql-vscode/package.json +++ b/extensions/ql-vscode/package.json @@ -463,8 +463,20 @@ }, { "type": "object", - "title": "Log insights", + "title": "Model Editor", "order": 9, + "properties": { + "codeQL.model.packLocation": { + "type": "string", + "default": ".github/codeql/extensions/${name}-${language}", + "markdownDescription": "Location for newly created CodeQL model packs. The location can be either absolute or relative. If relative, it is relative to the workspace root.\n\nThe following variables are supported:\n* **${database}** - the name of the database\n* **${language}** - the name of the language\n* **${name}** - the name of the GitHub repository, or the name of the database if the database was not downloaded from GitHub\n* **${owner}** - the owner of the GitHub repository, or an empty string if the database was not downloaded from GitHub" + } + } + }, + { + "type": "object", + "title": "Log insights", + "order": 10, "properties": { "codeQL.logInsights.joinOrderWarningThreshold": { "type": "number", @@ -478,7 +490,7 @@ { "type": "object", "title": "Telemetry", - "order": 10, + "order": 11, "properties": { "codeQL.telemetry.enableTelemetry": { "type": "boolean", diff --git a/extensions/ql-vscode/src/config.ts b/extensions/ql-vscode/src/config.ts index 03a79fbba..0cfbbed2c 100644 --- a/extensions/ql-vscode/src/config.ts +++ b/extensions/ql-vscode/src/config.ts @@ -13,6 +13,7 @@ import { FilterKey, SortKey, } from "./variant-analysis/shared/variant-analysis-filter-sort"; +import { substituteConfigVariables } from "./common/config-template"; export const ALL_SETTINGS: Setting[] = []; @@ -734,18 +735,28 @@ const LLM_GENERATION_DEV_ENDPOINT = new Setting( MODEL_SETTING, ); const MODEL_EVALUATION = new Setting("evaluation", MODEL_SETTING); -const EXTENSIONS_DIRECTORY = new Setting("extensionsDirectory", MODEL_SETTING); +const MODEL_PACK_LOCATION = new Setting("packLocation", MODEL_SETTING); const ENABLE_PYTHON = new Setting("enablePython", MODEL_SETTING); const ENABLE_ACCESS_PATH_SUGGESTIONS = new Setting( "enableAccessPathSuggestions", MODEL_SETTING, ); +export type ModelConfigPackVariables = { + database: string; + owner: string; + name: string; + language: string; +}; + export interface ModelConfig { flowGeneration: boolean; llmGeneration: boolean; showTypeModels: boolean; - getExtensionsDirectory(languageId: string): string | undefined; + getPackLocation( + languageId: string, + variables: ModelConfigPackVariables, + ): string; enablePython: boolean; enableAccessPathSuggestions: boolean; } @@ -787,10 +798,16 @@ export class ModelConfigListener extends ConfigListener implements ModelConfig { return !!MODEL_EVALUATION.getValue(); } - public getExtensionsDirectory(languageId: string): string | undefined { - return EXTENSIONS_DIRECTORY.getValue({ - languageId, - }); + public getPackLocation( + languageId: string, + variables: ModelConfigPackVariables, + ): string { + return substituteConfigVariables( + MODEL_PACK_LOCATION.getValue({ + languageId, + }), + variables, + ); } public get enablePython(): boolean { diff --git a/extensions/ql-vscode/src/model-editor/extension-pack-picker.ts b/extensions/ql-vscode/src/model-editor/extension-pack-picker.ts index 9962164ef..562ca7cff 100644 --- a/extensions/ql-vscode/src/model-editor/extension-pack-picker.ts +++ b/extensions/ql-vscode/src/model-editor/extension-pack-picker.ts @@ -2,7 +2,6 @@ import { join } from "path"; import { outputFile, pathExists, readFile } from "fs-extra"; import { dump as dumpYaml, load as loadYaml } from "js-yaml"; import type { CancellationToken } from "vscode"; -import { Uri } from "vscode"; import Ajv from "ajv"; import type { CodeQLCliServer } from "../codeql-cli/cli"; import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; @@ -14,10 +13,13 @@ import { getErrorMessage } from "../common/helpers-pure"; import type { ExtensionPack } from "./shared/extension-pack"; import type { NotificationLogger } from "../common/logging"; import { showAndLogErrorMessage } from "../common/logging"; -import type { ModelConfig } from "../config"; +import type { ModelConfig, ModelConfigPackVariables } from "../config"; import type { ExtensionPackName } from "./extension-pack-name"; import { autoNameExtensionPack, formatPackName } from "./extension-pack-name"; -import { autoPickExtensionsDirectory } from "./extensions-workspace-folder"; +import { + ensurePackLocationIsInWorkspaceFolder, + packLocationToAbsolute, +} from "./extensions-workspace-folder"; import type { ExtensionPackMetadata } from "./extension-pack-metadata"; import extensionPackMetadataSchemaJson from "./extension-pack-metadata.schema.json"; @@ -27,7 +29,7 @@ const extensionPackValidate = ajv.compile(extensionPackMetadataSchemaJson); export async function pickExtensionPack( cliServer: Pick, - databaseItem: Pick, + databaseItem: Pick, modelConfig: ModelConfig, logger: NotificationLogger, progress: ProgressCallback, @@ -64,20 +66,20 @@ export async function pickExtensionPack( maxStep, }); - // Get the `codeQL.model.extensionsDirectory` setting for the language - const userExtensionsDirectory = modelConfig.getExtensionsDirectory( - databaseItem.language, + // The default is .github/codeql/extensions/${name}-${language} + const packPath = await packLocationToAbsolute( + modelConfig.getPackLocation( + databaseItem.language, + getModelConfigPackVariables(databaseItem), + ), + logger, ); - - // If the setting is not set, automatically pick a suitable directory - const extensionsDirectory = userExtensionsDirectory - ? Uri.file(userExtensionsDirectory) - : await autoPickExtensionsDirectory(logger); - - if (!extensionsDirectory) { + if (!packPath) { return undefined; } + await ensurePackLocationIsInWorkspaceFolder(packPath, modelConfig, logger); + // Generate the name of the extension pack const packName = autoNameExtensionPack( databaseItem.name, @@ -139,14 +141,12 @@ export async function pickExtensionPack( return undefined; } - const packPath = join(extensionsDirectory.fsPath, packName.name); - if (await pathExists(packPath)) { void showAndLogErrorMessage( logger, `Directory ${packPath} already exists for extension pack ${formatPackName( packName, - )}`, + )}, but wasn't returned by codeql resolve qlpacks --kind extension --no-recursive`, ); return undefined; @@ -155,6 +155,26 @@ export async function pickExtensionPack( return writeExtensionPack(packPath, packName, databaseItem.language); } +function getModelConfigPackVariables( + databaseItem: Pick, +): ModelConfigPackVariables { + const database = databaseItem.name; + const language = databaseItem.language; + let name = databaseItem.name; + let owner = ""; + + if (databaseItem.origin?.type === "github") { + [owner, name] = databaseItem.origin.repository.split("/"); + } + + return { + database, + language, + name, + owner, + }; +} + async function writeExtensionPack( packPath: string, packName: ExtensionPackName, diff --git a/extensions/ql-vscode/src/model-editor/extensions-workspace-folder.ts b/extensions/ql-vscode/src/model-editor/extensions-workspace-folder.ts index 31d8d4746..077d9dd5b 100644 --- a/extensions/ql-vscode/src/model-editor/extensions-workspace-folder.ts +++ b/extensions/ql-vscode/src/model-editor/extensions-workspace-folder.ts @@ -1,9 +1,12 @@ import type { WorkspaceFolder } from "vscode"; import { FileType, Uri, workspace } from "vscode"; import { getOnDiskWorkspaceFoldersObjects } from "../common/vscode/workspace-folders"; -import { tmpdir } from "../common/files"; +import { containsPath, tmpdir } from "../common/files"; import type { NotificationLogger } from "../common/logging"; import { showAndLogErrorMessage } from "../common/logging"; +import { isAbsolute, normalize, resolve } from "path"; +import { nanoid } from "nanoid"; +import type { ModelConfig } from "../config"; /** * Returns the ancestors of this path in order from furthest to closest (i.e. root of filesystem to parent directory) @@ -22,26 +25,17 @@ function getAncestors(uri: Uri): Uri[] { return ancestors; } -async function getRootWorkspaceDirectory(): Promise { - // If there is a valid workspace file, just use its directory as the directory for the extensions - const workspaceFile = workspace.workspaceFile; - if (workspaceFile?.scheme === "file") { - return Uri.joinPath(workspaceFile, ".."); +function findCommonAncestor(uris: Uri[]): Uri | undefined { + if (uris.length === 0) { + return undefined; } - const allWorkspaceFolders = getOnDiskWorkspaceFoldersObjects(); - - // Get the system temp directory and convert it to a URI so it's normalized - const systemTmpdir = Uri.file(tmpdir()); - - const workspaceFolders = allWorkspaceFolders.filter((folder) => { - // Never use a workspace folder that is in the system temp directory - return !folder.uri.fsPath.startsWith(systemTmpdir.fsPath); - }); + if (uris.length === 1) { + return uris[0]; + } // Find the common root directory of all workspace folders by finding the longest common prefix - const commonRoot = workspaceFolders.reduce((commonRoot, folder) => { - const folderUri = folder.uri; + const commonRoot = uris.reduce((commonRoot, folderUri) => { const ancestors = getAncestors(folderUri); const minLength = Math.min(commonRoot.length, ancestors.length); @@ -55,10 +49,10 @@ async function getRootWorkspaceDirectory(): Promise { } return commonRoot.slice(0, commonLength); - }, getAncestors(workspaceFolders[0].uri)); + }, getAncestors(uris[0])); if (commonRoot.length === 0) { - return await findGitFolder(workspaceFolders); + return undefined; } // The path closest to the workspace folders is the last element of the common root @@ -67,6 +61,54 @@ async function getRootWorkspaceDirectory(): Promise { // If we are at the root of the filesystem, we can't go up any further and there's something // wrong, so just return undefined if (commonRootUri.fsPath === Uri.joinPath(commonRootUri, "..").fsPath) { + return undefined; + } + + return commonRootUri; +} + +/** + * Finds the root directory of this workspace. It is determined + * heuristically based on the on-disk workspace folders. + * + * The heuristic is as follows: + * 1. If there is a workspace file (`.code-workspace`), use the directory containing that file + * 1. If there is only 1 workspace folder, use that folder= + * 3. If there is a common root directory for all workspace folders, use that directory + * - Workspace folders in the system temp directory are ignored + * - If the common root directory is the root of the filesystem, then it's not used + * 5. If there is a .git directory in any workspace folder, use the directory containing that .git directory + * for which the .git directory is closest to a workspace folder + * 6. If none of the above apply, return `undefined` + */ +export async function getRootWorkspaceDirectory(): Promise { + // If there is a valid workspace file, just use its directory as the directory for the extensions + const workspaceFile = workspace.workspaceFile; + if (workspaceFile?.scheme === "file") { + return Uri.joinPath(workspaceFile, ".."); + } + + const allWorkspaceFolders = getOnDiskWorkspaceFoldersObjects(); + + if (allWorkspaceFolders.length === 1) { + return allWorkspaceFolders[0].uri; + } + + // Get the system temp directory and convert it to a URI so it's normalized + const systemTmpdir = Uri.file(tmpdir()); + + const workspaceFolders = allWorkspaceFolders.filter((folder) => { + // Never use a workspace folder that is in the system temp directory + return !folder.uri.fsPath.startsWith(systemTmpdir.fsPath); + }); + + // The path closest to the workspace folders is the last element of the common root + const commonRootUri = findCommonAncestor( + workspaceFolders.map((folder) => folder.uri), + ); + + // If there is no common root URI, try to find a .git folder in the workspace folders + if (commonRootUri === undefined) { return await findGitFolder(workspaceFolders); } @@ -126,90 +168,130 @@ async function findGitFolder( return closestFolder?.[1]; } -/** - * Finds a suitable directory for extension packs to be created in. This will - * always be a path ending in `.github/codeql/extensions`. The parent directory - * will be determined heuristically based on the on-disk workspace folders. - * - * The heuristic is as follows (`.github/codeql/extensions` is added automatically unless - * otherwise specified): - * 1. If there is only 1 workspace folder, use that folder - * 2. If there is a workspace folder for which the path ends in `.github/codeql/extensions`, use that folder - * - If there are multiple such folders, use the first one - * - Does not append `.github/codeql/extensions` to the path - * 3. If there is a workspace file (`.code-workspace`), use the directory containing that file - * 4. If there is a common root directory for all workspace folders, use that directory - * - Workspace folders in the system temp directory are ignored - * - If the common root directory is the root of the filesystem, then it's not used - * 5. If there is a .git directory in any workspace folder, use the directory containing that .git directory - * for which the .git directory is closest to a workspace folder - * 6. If none of the above apply, return `undefined` - */ -export async function autoPickExtensionsDirectory( +export async function packLocationToAbsolute( + packLocation: string, logger: NotificationLogger, -): Promise { - const workspaceFolders = getOnDiskWorkspaceFoldersObjects(); +): Promise { + let userPackLocation = packLocation.trim(); - // If there are no on-disk workspace folders, we can't do anything - if (workspaceFolders.length === 0) { + if (!isAbsolute(userPackLocation)) { + const rootDirectory = await getRootWorkspaceDirectory(); + if (!rootDirectory) { + void logger.log("Unable to determine root workspace directory"); + + return undefined; + } + + userPackLocation = resolve(rootDirectory.fsPath, userPackLocation); + } + + userPackLocation = normalize(userPackLocation); + + if (!isAbsolute(userPackLocation)) { + // This shouldn't happen, but just in case void showAndLogErrorMessage( logger, - `Could not find any on-disk workspace folders. Please ensure that you have opened a folder or workspace.`, + `Invalid pack location: ${userPackLocation}`, ); - return undefined; - } - - // If there's only 1 workspace folder, use the `.github/codeql/extensions` directory in that folder - if (workspaceFolders.length === 1) { - return Uri.joinPath( - workspaceFolders[0].uri, - ".github", - "codeql", - "extensions", - ); - } - - // Now try to find a workspace folder for which the path ends in `.github/codeql/extensions` - const workspaceFolderForExtensions = workspaceFolders.find((folder) => - // Using path instead of fsPath because path always uses forward slashes - folder.uri.path.endsWith(".github/codeql/extensions"), - ); - if (workspaceFolderForExtensions) { - return workspaceFolderForExtensions.uri; - } - - // Get the root workspace directory, i.e. the common root directory of all workspace folders - const rootDirectory = await getRootWorkspaceDirectory(); - if (!rootDirectory) { - void logger.log("Unable to determine root workspace directory"); return undefined; } - // We'll create a new workspace folder for the extensions in the root workspace directory - // at `.github/codeql/extensions` - const extensionsUri = Uri.joinPath( - rootDirectory, - ".github", - "codeql", - "extensions", + // If we are at the root of the filesystem, then something is wrong since + // this should never be the location of a pack + if (userPackLocation === resolve(userPackLocation, "..")) { + void showAndLogErrorMessage( + logger, + `Invalid pack location: ${userPackLocation}`, + ); + + return undefined; + } + + return userPackLocation; +} + +/** + * This function will try to add the pack location as a workspace folder if it's not already in a + * workspace folder and the workspace is a multi-root workspace. + */ +export async function ensurePackLocationIsInWorkspaceFolder( + packLocation: string, + modelConfig: ModelConfig, + logger: NotificationLogger, +): Promise { + const workspaceFolders = getOnDiskWorkspaceFoldersObjects(); + + const existsInWorkspaceFolder = workspaceFolders.some((folder) => + containsPath(folder.uri.fsPath, packLocation), ); + if (existsInWorkspaceFolder) { + // If the pack location is already in a workspace folder, we don't need to do anything + return; + } + + if (workspace.workspaceFile === undefined) { + // If we're not in a workspace, we can't add a workspace folder without reloading the window, + // so we'll not do anything + return; + } + + // To find the "correct" directory to add as a workspace folder, we'll generate a few different + // pack locations and find the common ancestor of the directories. This is the directory that + // we'll add as a workspace folder. + + // Generate a few different pack locations to get an accurate common ancestor + const otherPackLocations = await Promise.all( + Array.from({ length: 3 }).map(() => + packLocationToAbsolute( + modelConfig.getPackLocation(nanoid(), { + database: nanoid(), + language: nanoid(), + name: nanoid(), + owner: nanoid(), + }), + logger, + ), + ), + ); + + const otherPackLocationUris = otherPackLocations + .filter((loc): loc is string => loc !== undefined) + .map((loc) => Uri.file(loc)); + + if (otherPackLocationUris.length === 0) { + void logger.log( + `Failed to generate different pack locations, not adding workspace folder.`, + ); + return; + } + + const commonRootUri = findCommonAncestor([ + Uri.file(packLocation), + ...otherPackLocationUris, + ]); + + if (commonRootUri === undefined) { + void logger.log( + `Failed to find common ancestor for ${packLocation} and ${otherPackLocationUris[0].fsPath}, not adding workspace folder.`, + ); + return; + } + if ( !workspace.updateWorkspaceFolders( workspace.workspaceFolders?.length ?? 0, 0, { name: "CodeQL Extension Packs", - uri: extensionsUri, + uri: commonRootUri, }, ) ) { void logger.log( - `Failed to add workspace folder for extensions at ${extensionsUri.fsPath}`, + `Failed to add workspace folder for extensions at ${commonRootUri.fsPath}`, ); - return undefined; + return; } - - return extensionsUri; } diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/extension-pack-picker.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/extension-pack-picker.test.ts index 02073c4c8..6cf60892f 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/extension-pack-picker.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/extension-pack-picker.test.ts @@ -11,6 +11,7 @@ import type { ExtensionPack } from "../../../../src/model-editor/shared/extensio import { createMockLogger } from "../../../__mocks__/loggerMock"; import type { ModelConfig } from "../../../../src/config"; import { mockedObject } from "../../utils/mocking.helpers"; +import type { DatabaseItem } from "../../../../src/databases/local-databases"; describe("pickExtensionPack", () => { let tmpDir: string; @@ -19,9 +20,16 @@ describe("pickExtensionPack", () => { let autoExtensionPack: ExtensionPack; let qlPacks: QlpacksInfo; - const databaseItem = { + const databaseItem: Pick = { name: "github/vscode-codeql", language: "java", + origin: { + type: "github", + repository: "github/vscode-codeql", + databaseId: 123578, + databaseCreatedAt: "2021-01-01T00:00:00Z", + commitOid: "1234567890abcdef", + }, }; const progress = jest.fn(); @@ -67,7 +75,12 @@ describe("pickExtensionPack", () => { .mockReturnValue([workspaceFolder]); modelConfig = mockedObject({ - getExtensionsDirectory: jest.fn().mockReturnValue(undefined), + getPackLocation: jest + .fn() + .mockImplementation( + (language, { name }) => + `.github/codeql/extensions/${name}-${language}`, + ), }); }); @@ -90,10 +103,15 @@ describe("pickExtensionPack", () => { additionalPacks, true, ); - expect(modelConfig.getExtensionsDirectory).toHaveBeenCalledWith("java"); + expect(modelConfig.getPackLocation).toHaveBeenCalledWith("java", { + database: "github/vscode-codeql", + language: "java", + name: "vscode-codeql", + owner: "github", + }); }); - it("creates a new extension pack using default extensions directory", async () => { + it("creates a new extension pack using default pack location", async () => { const tmpDir = await dir({ unsafeCleanup: true, }); @@ -154,7 +172,12 @@ describe("pickExtensionPack", () => { dataExtensions: ["models/**/*.yml"], }); expect(cliServer.resolveQlpacks).toHaveBeenCalled(); - expect(modelConfig.getExtensionsDirectory).toHaveBeenCalledWith("java"); + expect(modelConfig.getPackLocation).toHaveBeenCalledWith("java", { + database: "github/vscode-codeql", + language: "java", + name: "vscode-codeql", + owner: "github", + }); expect( loadYaml(await readFile(join(newPackDir, "codeql-pack.yml"), "utf8")), @@ -169,22 +192,147 @@ describe("pickExtensionPack", () => { }); }); - it("creates a new extension pack when extensions directory is set in config", async () => { + it("creates a new extension pack when absolute custom pack location is set in config", async () => { + const packLocation = join(Uri.file(tmpDir).fsPath, "java/ql/lib"); + + const modelConfig = mockedObject({ + getPackLocation: jest.fn().mockReturnValue(packLocation), + }); + + const cliServer = mockCliServer({}); + + expect( + await pickExtensionPack( + cliServer, + databaseItem, + modelConfig, + logger, + progress, + token, + maxStep, + ), + ).toEqual({ + path: packLocation, + yamlPath: join(packLocation, "codeql-pack.yml"), + name: autoExtensionPackName, + version: "0.0.0", + language: "java", + extensionTargets: { + "codeql/java-all": "*", + }, + dataExtensions: ["models/**/*.yml"], + }); + expect(cliServer.resolveQlpacks).toHaveBeenCalled(); + expect(modelConfig.getPackLocation).toHaveBeenCalledWith("java", { + database: "github/vscode-codeql", + language: "java", + name: "vscode-codeql", + owner: "github", + }); + + expect( + loadYaml(await readFile(join(packLocation, "codeql-pack.yml"), "utf8")), + ).toEqual({ + name: autoExtensionPackName, + version: "0.0.0", + library: true, + extensionTargets: { + "codeql/java-all": "*", + }, + dataExtensions: ["models/**/*.yml"], + }); + }); + + it("creates a new extension pack when relative custom pack location is set in config", async () => { + const packLocation = join(Uri.file(tmpDir).fsPath, "java/ql/lib"); + + const modelConfig = mockedObject({ + getPackLocation: jest + .fn() + .mockImplementation((language) => `${language}/ql/lib`), + }); + + const cliServer = mockCliServer({}); + + expect( + await pickExtensionPack( + cliServer, + databaseItem, + modelConfig, + logger, + progress, + token, + maxStep, + ), + ).toEqual({ + path: packLocation, + yamlPath: join(packLocation, "codeql-pack.yml"), + name: autoExtensionPackName, + version: "0.0.0", + language: "java", + extensionTargets: { + "codeql/java-all": "*", + }, + dataExtensions: ["models/**/*.yml"], + }); + expect(cliServer.resolveQlpacks).toHaveBeenCalled(); + expect(modelConfig.getPackLocation).toHaveBeenCalledWith("java", { + database: "github/vscode-codeql", + language: "java", + name: "vscode-codeql", + owner: "github", + }); + + expect( + loadYaml(await readFile(join(packLocation, "codeql-pack.yml"), "utf8")), + ).toEqual({ + name: autoExtensionPackName, + version: "0.0.0", + library: true, + extensionTargets: { + "codeql/java-all": "*", + }, + dataExtensions: ["models/**/*.yml"], + }); + }); + + it("creates a new extension pack with non-github origin database", async () => { + const databaseItem: Pick = { + name: "vscode-codeql", + language: "java", + origin: { + type: "archive", + path: "/path/to/codeql-database.zip", + }, + }; + const tmpDir = await dir({ unsafeCleanup: true, }); - const configExtensionsDir = join( + workspaceFoldersSpy.mockReturnValue([ + { + uri: Uri.joinPath(Uri.file(tmpDir.path), "codeql-custom-queries-java"), + name: "codeql-custom-queries-java", + index: 2, + }, + ]); + + jest + .spyOn(workspace, "workspaceFile", "get") + .mockReturnValue( + Uri.joinPath(Uri.file(tmpDir.path), "workspace.code-workspace"), + ); + jest.spyOn(workspace, "updateWorkspaceFolders").mockReturnValue(true); + + const newPackDir = join( Uri.file(tmpDir.path).fsPath, - "my-custom-extensions-directory", + ".github", + "codeql", + "extensions", + "vscode-codeql-java", ); - const modelConfig = mockedObject({ - getExtensionsDirectory: jest.fn().mockReturnValue(configExtensionsDir), - }); - - const newPackDir = join(configExtensionsDir, "vscode-codeql-java"); - const cliServer = mockCliServer({}); expect( @@ -200,7 +348,7 @@ describe("pickExtensionPack", () => { ).toEqual({ path: newPackDir, yamlPath: join(newPackDir, "codeql-pack.yml"), - name: autoExtensionPackName, + name: "pack/vscode-codeql-java", version: "0.0.0", language: "java", extensionTargets: { @@ -209,12 +357,17 @@ describe("pickExtensionPack", () => { dataExtensions: ["models/**/*.yml"], }); expect(cliServer.resolveQlpacks).toHaveBeenCalled(); - expect(modelConfig.getExtensionsDirectory).toHaveBeenCalledWith("java"); + expect(modelConfig.getPackLocation).toHaveBeenCalledWith("java", { + database: "vscode-codeql", + language: "java", + name: "vscode-codeql", + owner: "", + }); expect( loadYaml(await readFile(join(newPackDir, "codeql-pack.yml"), "utf8")), ).toEqual({ - name: autoExtensionPackName, + name: "pack/vscode-codeql-java", version: "0.0.0", library: true, extensionTargets: { diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/extensions-workspace-folder.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/extensions-workspace-folder.test.ts index 4ba280ca6..96fdb024a 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/extensions-workspace-folder.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/extensions-workspace-folder.test.ts @@ -3,27 +3,28 @@ import { Uri, workspace } from "vscode"; import type { DirectoryResult } from "tmp-promise"; import { dir } from "tmp-promise"; import { join } from "path"; -import { autoPickExtensionsDirectory } from "../../../../src/model-editor/extensions-workspace-folder"; +import { + ensurePackLocationIsInWorkspaceFolder, + getRootWorkspaceDirectory, + packLocationToAbsolute, +} from "../../../../src/model-editor/extensions-workspace-folder"; import * as files from "../../../../src/common/files"; import { mkdirp } from "fs-extra"; import type { NotificationLogger } from "../../../../src/common/logging"; import { createMockLogger } from "../../../__mocks__/loggerMock"; +import { mockedObject } from "../../../mocked-object"; +import type { ModelConfig } from "../../../../src/config"; -describe("autoPickExtensionsDirectory", () => { +describe("getRootWorkspaceDirectory", () => { let tmpDir: DirectoryResult; let rootDirectory: Uri; - let extensionsDirectory: Uri; let workspaceFoldersSpy: jest.SpyInstance< readonly WorkspaceFolder[] | undefined, [] >; let workspaceFileSpy: jest.SpyInstance; - let updateWorkspaceFoldersSpy: jest.SpiedFunction< - typeof workspace.updateWorkspaceFolders - >; let mockedTmpDirUri: Uri; - let logger: NotificationLogger; beforeEach(async () => { tmpDir = await dir({ @@ -31,12 +32,6 @@ describe("autoPickExtensionsDirectory", () => { }); rootDirectory = Uri.joinPath(Uri.file(tmpDir.path), "root"); - extensionsDirectory = Uri.joinPath( - rootDirectory, - ".github", - "codeql", - "extensions", - ); const mockedTmpDir = join(tmpDir.path, ".tmp", "tmp"); mockedTmpDirUri = Uri.file(mockedTmpDir); @@ -47,44 +42,14 @@ describe("autoPickExtensionsDirectory", () => { workspaceFileSpy = jest .spyOn(workspace, "workspaceFile", "get") .mockReturnValue(undefined); - updateWorkspaceFoldersSpy = jest - .spyOn(workspace, "updateWorkspaceFolders") - .mockReturnValue(true); jest.spyOn(files, "tmpdir").mockReturnValue(mockedTmpDir); - - logger = createMockLogger(); }); afterEach(async () => { await tmpDir.cleanup(); }); - it("when a workspace folder with the correct path exists", async () => { - workspaceFoldersSpy.mockReturnValue([ - { - uri: Uri.joinPath(rootDirectory, "codeql-custom-queries-java"), - name: "codeql-custom-queries-java", - index: 0, - }, - { - uri: Uri.joinPath(rootDirectory, "codeql-custom-queries-python"), - name: "codeql-custom-queries-python", - index: 1, - }, - { - uri: extensionsDirectory, - name: "CodeQL Extension Packs", - index: 2, - }, - ]); - - expect(await autoPickExtensionsDirectory(logger)).toEqual( - extensionsDirectory, - ); - expect(updateWorkspaceFoldersSpy).not.toHaveBeenCalled(); - }); - it("when a workspace file exists", async () => { workspaceFoldersSpy.mockReturnValue([ { @@ -103,36 +68,7 @@ describe("autoPickExtensionsDirectory", () => { Uri.joinPath(rootDirectory, "workspace.code-workspace"), ); - expect(await autoPickExtensionsDirectory(logger)).toEqual( - extensionsDirectory, - ); - expect(updateWorkspaceFoldersSpy).toHaveBeenCalledWith(2, 0, { - name: "CodeQL Extension Packs", - uri: extensionsDirectory, - }); - }); - - it("when updating the workspace folders fails", async () => { - updateWorkspaceFoldersSpy.mockReturnValue(false); - - workspaceFoldersSpy.mockReturnValue([ - { - uri: Uri.file("/a/b/c"), - name: "codeql-custom-queries-java", - index: 0, - }, - { - uri: Uri.joinPath(rootDirectory, "codeql-custom-queries-python"), - name: "codeql-custom-queries-python", - index: 1, - }, - ]); - - workspaceFileSpy.mockReturnValue( - Uri.joinPath(rootDirectory, "workspace.code-workspace"), - ); - - expect(await autoPickExtensionsDirectory(logger)).toEqual(undefined); + expect(await getRootWorkspaceDirectory()).toEqual(rootDirectory); }); it("when a workspace file does not exist and there is a common root directory", async () => { @@ -149,13 +85,9 @@ describe("autoPickExtensionsDirectory", () => { }, ]); - expect(await autoPickExtensionsDirectory(logger)).toEqual( - extensionsDirectory, + expect((await getRootWorkspaceDirectory())?.fsPath).toEqual( + rootDirectory.fsPath, ); - expect(updateWorkspaceFoldersSpy).toHaveBeenCalledWith(2, 0, { - name: "CodeQL Extension Packs", - uri: extensionsDirectory, - }); }); it("when a workspace file does not exist and there is a temp dir as workspace folder", async () => { @@ -177,13 +109,9 @@ describe("autoPickExtensionsDirectory", () => { }, ]); - expect(await autoPickExtensionsDirectory(logger)).toEqual( - extensionsDirectory, + expect((await getRootWorkspaceDirectory())?.fsPath).toEqual( + rootDirectory.fsPath, ); - expect(updateWorkspaceFoldersSpy).toHaveBeenCalledWith(3, 0, { - name: "CodeQL Extension Packs", - uri: extensionsDirectory, - }); }); it("when a workspace file does not exist and there is no common root directory", async () => { @@ -200,8 +128,7 @@ describe("autoPickExtensionsDirectory", () => { }, ]); - expect(await autoPickExtensionsDirectory(logger)).toEqual(undefined); - expect(updateWorkspaceFoldersSpy).not.toHaveBeenCalled(); + expect(await getRootWorkspaceDirectory()).toBeUndefined(); }); it("when a workspace file does not exist and there is a .git folder", async () => { @@ -220,13 +147,7 @@ describe("autoPickExtensionsDirectory", () => { }, ]); - expect(await autoPickExtensionsDirectory(logger)).toEqual( - extensionsDirectory, - ); - expect(updateWorkspaceFoldersSpy).toHaveBeenCalledWith(2, 0, { - name: "CodeQL Extension Packs", - uri: extensionsDirectory, - }); + expect(await getRootWorkspaceDirectory()).toEqual(rootDirectory); }); it("when there is no on-disk workspace folder", async () => { @@ -238,10 +159,268 @@ describe("autoPickExtensionsDirectory", () => { }, ]); - expect(await autoPickExtensionsDirectory(logger)).toEqual(undefined); + expect(await getRootWorkspaceDirectory()).toBeUndefined(); + }); +}); + +describe("packLocationToAbsolute", () => { + let tmpDir: DirectoryResult; + let rootDirectory: Uri; + let extensionsDirectory: Uri; + + let logger: NotificationLogger; + + beforeEach(async () => { + tmpDir = await dir({ + unsafeCleanup: true, + }); + + rootDirectory = Uri.joinPath(Uri.file(tmpDir.path), "root"); + extensionsDirectory = Uri.joinPath( + rootDirectory, + ".github", + "codeql", + "extensions", + ); + + const mockedTmpDir = join(tmpDir.path, ".tmp", "tmp"); + + jest.spyOn(workspace, "workspaceFolders", "get").mockReturnValue([]); + jest + .spyOn(workspace, "workspaceFile", "get") + .mockReturnValue(Uri.joinPath(rootDirectory, "workspace.code-workspace")); + + jest.spyOn(files, "tmpdir").mockReturnValue(mockedTmpDir); + + logger = createMockLogger(); + }); + + afterEach(async () => { + await tmpDir.cleanup(); + }); + + it("when the location is absolute", async () => { + expect( + await packLocationToAbsolute(extensionsDirectory.fsPath, logger), + ).toEqual(extensionsDirectory.fsPath); + }); + + it("when the location is relative", async () => { + expect( + await packLocationToAbsolute(".github/codeql/extensions/my-pack", logger), + ).toEqual(Uri.joinPath(extensionsDirectory, "my-pack").fsPath); + }); + + it("when the location is invalid", async () => { + expect( + await packLocationToAbsolute("../".repeat(100), logger), + ).toBeUndefined(); + }); +}); + +describe("ensurePackLocationIsInWorkspaceFolder", () => { + let tmpDir: DirectoryResult; + let rootDirectory: Uri; + let extensionsDirectory: Uri; + let packLocation: string; + + let workspaceFoldersSpy: jest.SpyInstance< + readonly WorkspaceFolder[] | undefined, + [] + >; + let workspaceFileSpy: jest.SpyInstance; + let updateWorkspaceFoldersSpy: jest.SpiedFunction< + typeof workspace.updateWorkspaceFolders + >; + + let getPackLocation: jest.MockedFunction; + let modelConfig: ModelConfig; + let logger: NotificationLogger; + + beforeEach(async () => { + tmpDir = await dir({ + unsafeCleanup: true, + }); + + rootDirectory = Uri.joinPath(Uri.file(tmpDir.path), "root"); + extensionsDirectory = Uri.joinPath( + rootDirectory, + ".github", + "codeql", + "extensions", + ); + packLocation = Uri.joinPath(extensionsDirectory, "my-pack").fsPath; + + workspaceFoldersSpy = jest + .spyOn(workspace, "workspaceFolders", "get") + .mockReturnValue([ + { + uri: Uri.joinPath(rootDirectory, "codeql-custom-queries-java"), + name: "codeql-custom-queries-java", + index: 0, + }, + { + uri: Uri.joinPath(rootDirectory, "codeql-custom-queries-python"), + name: "codeql-custom-queries-python", + index: 1, + }, + ]); + workspaceFileSpy = jest + .spyOn(workspace, "workspaceFile", "get") + .mockReturnValue(Uri.joinPath(rootDirectory, "workspace.code-workspace")); + updateWorkspaceFoldersSpy = jest + .spyOn(workspace, "updateWorkspaceFolders") + .mockReturnValue(true); + + logger = createMockLogger(); + + getPackLocation = jest + .fn() + .mockImplementation( + (language, { name }) => + Uri.joinPath(extensionsDirectory, `${name}-${language}`).fsPath, + ); + modelConfig = mockedObject({ + getPackLocation, + }); + }); + + afterEach(async () => { + await tmpDir.cleanup(); + }); + + it("when there is no workspace file", async () => { + workspaceFileSpy.mockReturnValue(undefined); + + await ensurePackLocationIsInWorkspaceFolder( + packLocation, + modelConfig, + logger, + ); expect(updateWorkspaceFoldersSpy).not.toHaveBeenCalled(); - expect(logger.showErrorMessage).toHaveBeenCalledWith( - "Could not find any on-disk workspace folders. Please ensure that you have opened a folder or workspace.", + }); + + it("when a workspace folder with the correct path exists", async () => { + workspaceFoldersSpy.mockReturnValue([ + { + uri: Uri.joinPath(rootDirectory, "codeql-custom-queries-java"), + name: "codeql-custom-queries-java", + index: 0, + }, + { + uri: Uri.joinPath(rootDirectory, "codeql-custom-queries-python"), + name: "codeql-custom-queries-python", + index: 1, + }, + { + uri: extensionsDirectory, + name: "CodeQL Extension Packs", + index: 2, + }, + ]); + + await ensurePackLocationIsInWorkspaceFolder( + packLocation, + modelConfig, + logger, + ); + expect(updateWorkspaceFoldersSpy).not.toHaveBeenCalled(); + }); + + it("when a workspace folder with the correct path does not exist in an unsaved workspace", async () => { + workspaceFileSpy.mockReturnValue(Uri.parse("untitled:1555503116870")); + workspaceFoldersSpy.mockReturnValue([ + { + uri: Uri.file("/a/b/c"), + name: "codeql-custom-queries-java", + index: 0, + }, + { + uri: Uri.joinPath(rootDirectory, "codeql-custom-queries-python"), + name: "codeql-custom-queries-python", + index: 1, + }, + ]); + + await ensurePackLocationIsInWorkspaceFolder( + packLocation, + modelConfig, + logger, + ); + expect(updateWorkspaceFoldersSpy).toHaveBeenLastCalledWith(2, 0, { + name: "CodeQL Extension Packs", + uri: expect.any(Uri), + }); + expect(updateWorkspaceFoldersSpy.mock.lastCall?.[2].uri.fsPath).toEqual( + extensionsDirectory.fsPath, + ); + }); + + it("when a workspace folder with the correct path does not exist in a saved workspace", async () => { + workspaceFoldersSpy.mockReturnValue([ + { + uri: Uri.file("/a/b/c"), + name: "codeql-custom-queries-java", + index: 0, + }, + { + uri: Uri.joinPath(rootDirectory, "codeql-custom-queries-python"), + name: "codeql-custom-queries-python", + index: 1, + }, + ]); + + await ensurePackLocationIsInWorkspaceFolder( + packLocation, + modelConfig, + logger, + ); + expect(updateWorkspaceFoldersSpy).toHaveBeenLastCalledWith(2, 0, { + name: "CodeQL Extension Packs", + uri: expect.any(Uri), + }); + expect(updateWorkspaceFoldersSpy.mock.lastCall?.[2].uri.fsPath).toEqual( + extensionsDirectory.fsPath, + ); + }); + + it("when all other pack locations are invalid", async () => { + getPackLocation.mockReturnValue("/"); + + await ensurePackLocationIsInWorkspaceFolder( + packLocation, + modelConfig, + logger, + ); + + expect(updateWorkspaceFoldersSpy).not.toHaveBeenCalled(); + }); + + it("when there is no common root directory", async () => { + getPackLocation.mockImplementation( + (language, { name }) => `/${name}-${language}`, + ); + + await ensurePackLocationIsInWorkspaceFolder( + packLocation, + modelConfig, + logger, + ); + + expect(updateWorkspaceFoldersSpy).not.toHaveBeenCalled(); + }); + + it("when updating the workspace folders fails", async () => { + updateWorkspaceFoldersSpy.mockReturnValue(false); + + await ensurePackLocationIsInWorkspaceFolder( + packLocation, + modelConfig, + logger, + ); + + expect(logger.log).toHaveBeenCalledWith( + `Failed to add workspace folder for extensions at ${extensionsDirectory.fsPath}`, ); }); });