From 549884d507f0b61d5bf39be8b43248d9c9eac499 Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Fri, 16 Jun 2023 13:31:31 +0200 Subject: [PATCH 01/11] Automatically name extension packs This will change how extension packs are named in the data extensions editor. Before, the user had to pick a workspace folder and a name for the extension pack. Now, the workspace folder will be picked automatically if we can detect it (i.e. it follows the naming structure we expect), or the user will still need to select it. The extension pack name is always auto-generated based on the database name and the database language. This adds a new `codeQL.dataExtensions.disableAutoNameExtensionPack` setting to disable this behavior while we are still working on changing how the data extensions editor works. --- extensions/ql-vscode/src/config.ts | 8 + .../extension-pack-picker.ts | 217 +++++++++++++++-- .../extension-pack-picker.test.ts | 229 +++++++++++++++++- 3 files changed, 421 insertions(+), 33 deletions(-) diff --git a/extensions/ql-vscode/src/config.ts b/extensions/ql-vscode/src/config.ts index 48c168773d2..02b2fb1accc 100644 --- a/extensions/ql-vscode/src/config.ts +++ b/extensions/ql-vscode/src/config.ts @@ -714,7 +714,15 @@ export function showQueriesPanel(): boolean { const DATA_EXTENSIONS = new Setting("dataExtensions", ROOT_SETTING); const LLM_GENERATION = new Setting("llmGeneration", DATA_EXTENSIONS); +const DISABLE_AUTO_NAME_EXTENSION_PACK = new Setting( + "disableAutoNameExtensionPack", + DATA_EXTENSIONS, +); export function showLlmGeneration(): boolean { return !!LLM_GENERATION.getValue(); } + +export function disableAutoNameExtensionPack(): boolean { + return !!DISABLE_AUTO_NAME_EXTENSION_PACK.getValue(); +} diff --git a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts index 2e35c698c4d..588fadb109c 100644 --- a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts +++ b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts @@ -2,8 +2,8 @@ import { join, relative, resolve, sep } from "path"; import { outputFile, pathExists, readFile } from "fs-extra"; import { dump as dumpYaml, load as loadYaml } from "js-yaml"; import { minimatch } from "minimatch"; -import { CancellationToken, window } from "vscode"; -import { CodeQLCliServer } from "../codeql-cli/cli"; +import { CancellationToken, window, WorkspaceFolder } from "vscode"; +import { CodeQLCliServer, QlpacksInfo } from "../codeql-cli/cli"; import { getOnDiskWorkspaceFolders, getOnDiskWorkspaceFoldersObjects, @@ -15,6 +15,7 @@ import { getErrorMessage } from "../pure/helpers-pure"; import { ExtensionPack, ExtensionPackModelFile } from "./shared/extension-pack"; import { NotificationLogger, showAndLogErrorMessage } from "../common/logging"; import { containsPath } from "../pure/files"; +import { disableAutoNameExtensionPack } from "../config"; const maxStep = 3; @@ -79,6 +80,21 @@ async function pickExtensionPack( true, ); + if (!disableAutoNameExtensionPack()) { + progress({ + message: "Creating extension pack...", + step: 2, + maxStep, + }); + + return autoCreateExtensionPack( + databaseItem.name, + databaseItem.language, + extensionPacksInfo, + logger, + ); + } + if (Object.keys(extensionPacksInfo).length === 0) { return pickNewExtensionPack(databaseItem, token); } @@ -239,26 +255,15 @@ async function pickNewExtensionPack( databaseItem: Pick, token: CancellationToken, ): Promise { - const workspaceFolders = getOnDiskWorkspaceFoldersObjects(); - const workspaceFolderOptions = workspaceFolders.map((folder) => ({ - label: folder.name, - detail: folder.uri.fsPath, - path: folder.uri.fsPath, - })); - - // We're not using window.showWorkspaceFolderPick because that also includes the database source folders while - // we only want to include on-disk workspace folders. - const workspaceFolder = await window.showQuickPick(workspaceFolderOptions, { - title: "Select workspace folder to create extension pack in", - }); + const workspaceFolder = await askForWorkspaceFolder(); if (!workspaceFolder) { return undefined; } - let examplePackName = `${databaseItem.name}-extensions`; - if (!examplePackName.includes("/")) { - examplePackName = `pack/${examplePackName}`; - } + const examplePackName = autoNameExtensionPack( + databaseItem.name, + databaseItem.language, + ); const packName = await window.showInputBox( { @@ -283,7 +288,7 @@ async function pickNewExtensionPack( return "Invalid package name: a pack name must contain only lowercase ASCII letters, ASCII digits, and hyphens"; } - const packPath = join(workspaceFolder.path, matches.groups.name); + const packPath = join(workspaceFolder.uri.fsPath, matches.groups.name); if (await pathExists(packPath)) { return `A pack already exists at ${packPath}`; } @@ -303,12 +308,145 @@ async function pickNewExtensionPack( } const name = matches.groups.name; - const packPath = join(workspaceFolder.path, name); + const packPath = join(workspaceFolder.uri.fsPath, name); if (await pathExists(packPath)) { return undefined; } + return writeExtensionPack(packPath, packName, databaseItem.language); +} + +async function autoCreateExtensionPack( + name: string, + language: string, + extensionPacksInfo: QlpacksInfo, + logger: NotificationLogger, +): Promise { + const workspaceFolder = await autoPickWorkspaceFolder(language); + if (!workspaceFolder) { + return undefined; + } + + const packName = autoNameExtensionPack(name, language); + if (!packName) { + void showAndLogErrorMessage( + logger, + `Could not automatically name extension pack for database ${name}`, + ); + + return undefined; + } + + const existingExtensionPackPaths = extensionPacksInfo[packName]; + if (existingExtensionPackPaths?.length === 1) { + let extensionPack: ExtensionPack; + try { + extensionPack = await readExtensionPack(existingExtensionPackPaths[0]); + } catch (e: unknown) { + void showAndLogErrorMessage( + logger, + `Could not read extension pack ${packName}`, + { + fullMessage: `Could not read extension pack ${packName} at ${ + existingExtensionPackPaths[0] + }: ${getErrorMessage(e)}`, + }, + ); + + return undefined; + } + + return extensionPack; + } else if (existingExtensionPackPaths?.length > 1) { + void showAndLogErrorMessage( + logger, + `Extension pack ${packName} resolves to multiple paths`, + { + fullMessage: `Extension pack ${packName} resolves to multiple paths: ${existingExtensionPackPaths.join( + ", ", + )}`, + }, + ); + + return undefined; + } + + const matches = packNameRegex.exec(packName); + if (!matches?.groups) { + void showAndLogErrorMessage( + logger, + `Extension pack ${packName} does not have a valid name`, + ); + + return undefined; + } + + const unscopedName = matches.groups.name; + const packPath = join(workspaceFolder.uri.fsPath, unscopedName); + + if (await pathExists(packPath)) { + void showAndLogErrorMessage( + logger, + `Directory ${packPath} already exists for extension pack ${packName}`, + ); + + return undefined; + } + + return writeExtensionPack(packPath, packName, language); +} + +async function autoPickWorkspaceFolder( + language: string, +): Promise { + const workspaceFolders = getOnDiskWorkspaceFoldersObjects(); + + if (workspaceFolders.length === 1) { + return workspaceFolders[0]; + } + const starterWorkspaceFolderForLanguage = workspaceFolders.find( + (folder) => folder.name === `codeql-custom-queries-${language}`, + ); + if (starterWorkspaceFolderForLanguage) { + return starterWorkspaceFolderForLanguage; + } + + const workspaceFolderForLanguage = workspaceFolders.find((folder) => + folder.name.endsWith(`-${language}`), + ); + if (workspaceFolderForLanguage) { + return workspaceFolderForLanguage; + } + + return askForWorkspaceFolder(); +} + +async function askForWorkspaceFolder(): Promise { + const workspaceFolders = getOnDiskWorkspaceFoldersObjects(); + const workspaceFolderOptions = workspaceFolders.map((folder) => ({ + label: folder.name, + detail: folder.uri.fsPath, + folder, + })); + + // We're not using window.showWorkspaceFolderPick because that also includes the database source folders while + // we only want to include on-disk workspace folders. + const workspaceFolder = await window.showQuickPick(workspaceFolderOptions, { + title: "Select workspace folder to create extension pack in", + }); + if (!workspaceFolder) { + return undefined; + } + + return workspaceFolder.folder; +} + +async function writeExtensionPack( + packPath: string, + packName: string, + language: string, +): Promise { const packYamlPath = join(packPath, "codeql-pack.yml"); const extensionPack: ExtensionPack = { @@ -317,7 +455,7 @@ async function pickNewExtensionPack( name: packName, version: "0.0.0", extensionTargets: { - [`codeql/${databaseItem.language}-all`]: "*", + [`codeql/${language}-all`]: "*", }, dataExtensions: ["models/**/*.yml"], }; @@ -420,3 +558,40 @@ async function readExtensionPack(path: string): Promise { dataExtensions, }; } + +function autoNameExtensionPack( + name: string, + language: string, +): string | undefined { + let packName = `${name}-${language}`; + if (!packName.includes("/")) { + packName = `pack/${packName}`; + } + + const parts = packName.split("/"); + const sanitizedParts = parts.map((part) => sanitizeExtensionPackName(part)); + + // This will ensure there's only 1 slash + packName = `${sanitizedParts[0]}/${sanitizedParts.slice(1).join("-")}`; + + return packName; +} + +function sanitizeExtensionPackName(name: string) { + // Lowercase everything + name = name.toLowerCase(); + + // Replace all spaces, dots, and underscores with hyphens + name = name.replaceAll(/[\s._]+/g, "-"); + + // Replace all characters which are not allowed by empty strings + name = name.replaceAll(/[^a-z0-9-]/g, ""); + + // Remove any leading or trailing hyphens + name = name.replaceAll(/^-|-$/g, ""); + + // Remove any duplicate hyphens + name = name.replaceAll(/-{2,}/g, "-"); + + return name; +} diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extension-pack-picker.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extension-pack-picker.test.ts index 044ae6481fb..5662e19f2c9 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extension-pack-picker.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extension-pack-picker.test.ts @@ -1,4 +1,11 @@ -import { CancellationTokenSource, QuickPickItem, window } from "vscode"; +import { + CancellationTokenSource, + QuickPickItem, + Uri, + window, + workspace, + WorkspaceFolder, +} from "vscode"; import { dump as dumpYaml, load as loadYaml } from "js-yaml"; import { outputFile, readFile } from "fs-extra"; import { join } from "path"; @@ -8,6 +15,8 @@ import { ResolveExtensionsResult, } from "../../../../src/codeql-cli/cli"; +import * as config from "../../../../src/config"; + import { pickExtensionPackModelFile } from "../../../../src/data-extensions-editor/extension-pack-picker"; import { ExtensionPack } from "../../../../src/data-extensions-editor/shared/extension-pack"; import { createMockLogger } from "../../../__mocks__/loggerMock"; @@ -16,8 +25,10 @@ describe("pickExtensionPackModelFile", () => { let tmpDir: string; let extensionPackPath: string; let anotherExtensionPackPath: string; + let autoExtensionPackPath: string; let extensionPack: ExtensionPack; let anotherExtensionPack: ExtensionPack; + let autoExtensionPack: ExtensionPack; let qlPacks: QlpacksInfo; let extensions: ResolveExtensionsResult; @@ -32,6 +43,12 @@ describe("pickExtensionPackModelFile", () => { const progress = jest.fn(); let showQuickPickSpy: jest.SpiedFunction; let showInputBoxSpy: jest.SpiedFunction; + let disableAutoNameExtensionPackSpy: jest.SpiedFunction< + typeof config.disableAutoNameExtensionPack + >; + let workspaceFoldersSpy: jest.SpyInstance; + let additionalPacks: string[]; + let workspaceFolder: WorkspaceFolder; const logger = createMockLogger(); @@ -44,10 +61,12 @@ describe("pickExtensionPackModelFile", () => { extensionPackPath = join(tmpDir, "my-extension-pack"); anotherExtensionPackPath = join(tmpDir, "another-extension-pack"); + autoExtensionPackPath = join(tmpDir, "vscode-codeql-java"); qlPacks = { "my-extension-pack": [extensionPackPath], "another-extension-pack": [anotherExtensionPackPath], + "github/vscode-codeql-java": [autoExtensionPackPath], }; extensions = { models: [], @@ -59,6 +78,13 @@ describe("pickExtensionPackModelFile", () => { predicate: "sinkModel", }, ], + [autoExtensionPackPath]: [ + { + file: join(autoExtensionPackPath, "models", "model.yml"), + index: 0, + predicate: "sinkModel", + }, + ], }, }; @@ -70,6 +96,10 @@ describe("pickExtensionPackModelFile", () => { anotherExtensionPackPath, "another-extension-pack", ); + autoExtensionPack = await createMockExtensionPack( + autoExtensionPackPath, + "github/vscode-codeql-java", + ); showQuickPickSpy = jest .spyOn(window, "showQuickPick") @@ -77,6 +107,19 @@ describe("pickExtensionPackModelFile", () => { showInputBoxSpy = jest .spyOn(window, "showInputBox") .mockRejectedValue(new Error("Unexpected call to showInputBox")); + disableAutoNameExtensionPackSpy = jest + .spyOn(config, "disableAutoNameExtensionPack") + .mockReturnValue(true); + + workspaceFolder = { + uri: Uri.file(tmpDir), + name: "codeql-custom-queries-java", + index: 0, + }; + additionalPacks = [tmpDir]; + workspaceFoldersSpy = jest + .spyOn(workspace, "workspaceFolders", "get") + .mockReturnValue([workspaceFolder]); }); it("allows choosing an existing extension pack and model file", async () => { @@ -120,6 +163,12 @@ describe("pickExtensionPackModelFile", () => { detail: anotherExtensionPackPath, extensionPack: anotherExtensionPack, }, + { + label: "github/vscode-codeql-java", + description: "0.0.0", + detail: autoExtensionPackPath, + extensionPack: autoExtensionPack, + }, { label: expect.stringMatching(/create/i), extensionPack: null, @@ -147,11 +196,14 @@ describe("pickExtensionPackModelFile", () => { token, ); expect(cliServer.resolveQlpacks).toHaveBeenCalledTimes(1); - expect(cliServer.resolveQlpacks).toHaveBeenCalledWith([], true); + expect(cliServer.resolveQlpacks).toHaveBeenCalledWith( + additionalPacks, + true, + ); expect(cliServer.resolveExtensions).toHaveBeenCalledTimes(1); expect(cliServer.resolveExtensions).toHaveBeenCalledWith( extensionPackPath, - [], + additionalPacks, ); }); @@ -190,12 +242,146 @@ describe("pickExtensionPackModelFile", () => { token, ); expect(cliServer.resolveQlpacks).toHaveBeenCalledTimes(1); - expect(cliServer.resolveQlpacks).toHaveBeenCalledWith([], true); + expect(cliServer.resolveQlpacks).toHaveBeenCalledWith( + additionalPacks, + true, + ); expect(cliServer.resolveExtensions).toHaveBeenCalledTimes(1); expect(cliServer.resolveExtensions).toHaveBeenCalledWith( extensionPackPath, - [], + additionalPacks, + ); + }); + + it("automatically selects an extension pack and allows selecting an existing model file", async () => { + disableAutoNameExtensionPackSpy.mockReturnValue(false); + + const modelPath = join(autoExtensionPackPath, "models", "model.yml"); + + const cliServer = mockCliServer(qlPacks, extensions); + + showQuickPickSpy.mockResolvedValueOnce({ + label: "models/model.yml", + file: modelPath, + } as QuickPickItem); + + expect( + await pickExtensionPackModelFile( + cliServer, + databaseItem, + logger, + progress, + token, + ), + ).toEqual({ + filename: modelPath, + extensionPack: autoExtensionPack, + }); + expect(showQuickPickSpy).toHaveBeenCalledTimes(1); + expect(showQuickPickSpy).toHaveBeenCalledWith( + [ + { + label: "models/model.yml", + file: modelPath, + }, + { + label: expect.stringMatching(/create/i), + file: null, + }, + ], + { + title: expect.any(String), + }, + token, + ); + expect(cliServer.resolveQlpacks).toHaveBeenCalledTimes(1); + expect(cliServer.resolveQlpacks).toHaveBeenCalledWith( + additionalPacks, + true, + ); + expect(cliServer.resolveExtensions).toHaveBeenCalledTimes(1); + expect(cliServer.resolveExtensions).toHaveBeenCalledWith( + autoExtensionPackPath, + additionalPacks, + ); + }); + + it("automatically creates an extension pack and allows creating a new model file", async () => { + disableAutoNameExtensionPackSpy.mockReturnValue(false); + + const tmpDir = await dir({ + unsafeCleanup: true, + }); + + workspaceFoldersSpy.mockReturnValue([ + { + uri: Uri.file("/b/a/c"), + name: "my-workspace", + index: 0, + }, + { + uri: Uri.file("/a/b/c"), + name: "codeql-custom-queries-csharp", + index: 1, + }, + { + uri: Uri.file(tmpDir.path), + name: "codeql-custom-queries-java", + index: 2, + }, + ]); + + const newPackDir = join(tmpDir.path, "vscode-codeql-java"); + + const cliServer = mockCliServer({}, { models: [], data: {} }); + + showInputBoxSpy.mockResolvedValue("models/my-model.yml"); + + expect( + await pickExtensionPackModelFile( + cliServer, + databaseItem, + logger, + progress, + token, + ), + ).toEqual({ + filename: join(newPackDir, "models", "my-model.yml"), + extensionPack: { + path: newPackDir, + yamlPath: join(newPackDir, "codeql-pack.yml"), + name: "github/vscode-codeql-java", + version: "0.0.0", + extensionTargets: { + "codeql/java-all": "*", + }, + dataExtensions: ["models/**/*.yml"], + }, + }); + expect(showQuickPickSpy).not.toHaveBeenCalled(); + expect(showInputBoxSpy).toHaveBeenCalledTimes(1); + expect(showInputBoxSpy).toHaveBeenCalledWith( + { + title: expect.stringMatching(/model file/), + value: "models/github.vscode-codeql.model.yml", + validateInput: expect.any(Function), + }, + token, ); + expect(cliServer.resolveQlpacks).toHaveBeenCalled(); + expect(cliServer.resolveExtensions).toHaveBeenCalled(); + + expect( + loadYaml(await readFile(join(newPackDir, "codeql-pack.yml"), "utf8")), + ).toEqual({ + name: "github/vscode-codeql-java", + version: "0.0.0", + library: true, + extensionTargets: { + "codeql/java-all": "*", + }, + dataExtensions: ["models/**/*.yml"], + }); }); it("allows cancelling the extension pack prompt", async () => { @@ -227,7 +413,11 @@ describe("pickExtensionPackModelFile", () => { showQuickPickSpy.mockResolvedValueOnce({ label: "codeql-custom-queries-java", - path: tmpDir.path, + folder: { + uri: Uri.file(tmpDir.path), + name: "codeql-custom-queries-java", + index: 0, + }, } as QuickPickItem); showInputBoxSpy.mockResolvedValueOnce("pack/new-extension-pack"); showInputBoxSpy.mockResolvedValue("models/my-model.yml"); @@ -259,7 +449,7 @@ describe("pickExtensionPackModelFile", () => { { title: expect.stringMatching(/extension pack/i), prompt: expect.stringMatching(/extension pack/i), - placeHolder: expect.stringMatching(/github\/vscode-codeql-extensions/), + placeHolder: expect.stringMatching(/github\/vscode-codeql-java/), validateInput: expect.any(Function), }, token, @@ -299,7 +489,11 @@ describe("pickExtensionPackModelFile", () => { showQuickPickSpy.mockResolvedValueOnce({ label: "codeql-custom-queries-java", - path: tmpDir.path, + folder: { + uri: Uri.file(tmpDir.path), + name: "codeql-custom-queries-java", + index: 0, + }, } as QuickPickItem); showInputBoxSpy.mockResolvedValueOnce("pack/new-extension-pack"); showInputBoxSpy.mockResolvedValue("models/my-model.yml"); @@ -334,7 +528,7 @@ describe("pickExtensionPackModelFile", () => { { title: expect.stringMatching(/extension pack/i), prompt: expect.stringMatching(/extension pack/i), - placeHolder: expect.stringMatching(/github\/vscode-codeql-extensions/), + placeHolder: expect.stringMatching(/github\/vscode-codeql-csharp/), validateInput: expect.any(Function), }, token, @@ -388,7 +582,11 @@ describe("pickExtensionPackModelFile", () => { showQuickPickSpy.mockResolvedValueOnce({ label: "codeql-custom-queries-java", - path: "/a/b/c", + folder: { + uri: Uri.file("/a/b/c"), + name: "codeql-custom-queries-java", + index: 0, + }, } as QuickPickItem); showInputBoxSpy.mockResolvedValueOnce(undefined); @@ -777,7 +975,11 @@ describe("pickExtensionPackModelFile", () => { showQuickPickSpy.mockResolvedValueOnce({ label: "a", - path: "/a/b/c", + folder: { + uri: Uri.file("/a/b/c"), + name: "a", + index: 0, + }, } as QuickPickItem); showInputBoxSpy.mockResolvedValue(undefined); @@ -1014,7 +1216,10 @@ describe("pickExtensionPackModelFile", () => { token, ); expect(cliServer.resolveQlpacks).toHaveBeenCalledTimes(1); - expect(cliServer.resolveQlpacks).toHaveBeenCalledWith([], true); + expect(cliServer.resolveQlpacks).toHaveBeenCalledWith( + additionalPacks, + true, + ); expect(cliServer.resolveExtensions).not.toHaveBeenCalled(); }); }); From 5d83ac84e3fb1e916741ab402202d5f97c866dd3 Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Fri, 16 Jun 2023 16:01:25 +0200 Subject: [PATCH 02/11] Fix tests on Windows --- .../extension-pack-picker.test.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extension-pack-picker.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extension-pack-picker.test.ts index 5662e19f2c9..229192a4b4c 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extension-pack-picker.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extension-pack-picker.test.ts @@ -59,9 +59,12 @@ describe("pickExtensionPackModelFile", () => { }) ).path; - extensionPackPath = join(tmpDir, "my-extension-pack"); - anotherExtensionPackPath = join(tmpDir, "another-extension-pack"); - autoExtensionPackPath = join(tmpDir, "vscode-codeql-java"); + // Uri.file(...).fsPath normalizes the filenames so we can properly compare them on Windows + extensionPackPath = Uri.file(join(tmpDir, "my-extension-pack")).fsPath; + anotherExtensionPackPath = Uri.file( + join(tmpDir, "another-extension-pack"), + ).fsPath; + autoExtensionPackPath = Uri.file(join(tmpDir, "vscode-codeql-java")).fsPath; qlPacks = { "my-extension-pack": [extensionPackPath], @@ -116,7 +119,7 @@ describe("pickExtensionPackModelFile", () => { name: "codeql-custom-queries-java", index: 0, }; - additionalPacks = [tmpDir]; + additionalPacks = [Uri.file(tmpDir).fsPath]; workspaceFoldersSpy = jest .spyOn(workspace, "workspaceFolders", "get") .mockReturnValue([workspaceFolder]); @@ -331,7 +334,7 @@ describe("pickExtensionPackModelFile", () => { }, ]); - const newPackDir = join(tmpDir.path, "vscode-codeql-java"); + const newPackDir = join(Uri.file(tmpDir.path).fsPath, "vscode-codeql-java"); const cliServer = mockCliServer({}, { models: [], data: {} }); @@ -409,7 +412,7 @@ describe("pickExtensionPackModelFile", () => { unsafeCleanup: true, }); - const newPackDir = join(tmpDir.path, "new-extension-pack"); + const newPackDir = join(Uri.file(tmpDir.path).fsPath, "new-extension-pack"); showQuickPickSpy.mockResolvedValueOnce({ label: "codeql-custom-queries-java", @@ -485,7 +488,7 @@ describe("pickExtensionPackModelFile", () => { unsafeCleanup: true, }); - const newPackDir = join(tmpDir.path, "new-extension-pack"); + const newPackDir = join(Uri.file(tmpDir.path).fsPath, "new-extension-pack"); showQuickPickSpy.mockResolvedValueOnce({ label: "codeql-custom-queries-java", From 8980aabbfca50fb129da32968508b7d04334ba13 Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Mon, 19 Jun 2023 13:34:35 +0200 Subject: [PATCH 03/11] Split flows for checking existing extension pack --- .../src/data-extensions-editor/extension-pack-picker.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts index 588fadb109c..d2f8dca7be4 100644 --- a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts +++ b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts @@ -339,6 +339,7 @@ async function autoCreateExtensionPack( } const existingExtensionPackPaths = extensionPacksInfo[packName]; + // If there is already an extension pack with this name, use it if it is valid if (existingExtensionPackPaths?.length === 1) { let extensionPack: ExtensionPack; try { @@ -358,7 +359,11 @@ async function autoCreateExtensionPack( } return extensionPack; - } else if (existingExtensionPackPaths?.length > 1) { + } + + // If there is already an existing extension pack with this name, but it resolves + // to multiple paths, then we can't use it + if (existingExtensionPackPaths?.length > 1) { void showAndLogErrorMessage( logger, `Extension pack ${packName} resolves to multiple paths`, From 3c60708b55eb054cc2c870a3ede4e8f159e7deef Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Mon, 19 Jun 2023 13:47:27 +0200 Subject: [PATCH 04/11] Separate pack naming and create interface --- .../extension-pack-name.ts | 88 ++++++++++++ .../extension-pack-picker.ts | 126 ++++++------------ .../extension-pack-name.test.ts | 53 ++++++++ 3 files changed, 180 insertions(+), 87 deletions(-) create mode 100644 extensions/ql-vscode/src/data-extensions-editor/extension-pack-name.ts create mode 100644 extensions/ql-vscode/test/unit-tests/data-extensions-editor/extension-pack-name.test.ts diff --git a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-name.ts b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-name.ts new file mode 100644 index 00000000000..d148dc1a297 --- /dev/null +++ b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-name.ts @@ -0,0 +1,88 @@ +const packNamePartRegex = /[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/; +const packNameRegex = new RegExp( + `^(?${packNamePartRegex.source})/(?${packNamePartRegex.source})$`, +); +const packNameLength = 128; + +export interface ExtensionPackName { + scope: string; + name: string; +} + +export function formatPackName(packName: ExtensionPackName): string { + return `${packName.scope}/${packName.name}`; +} + +export function autoNameExtensionPack( + name: string, + language: string, +): ExtensionPackName | undefined { + let packName = `${name}-${language}`; + if (!packName.includes("/")) { + packName = `pack/${packName}`; + } + + const parts = packName.split("/"); + const sanitizedParts = parts.map((part) => sanitizeExtensionPackName(part)); + + return { + scope: sanitizedParts[0], + // This will ensure there's only 1 slash + name: sanitizedParts.slice(1).join("-"), + }; +} + +function sanitizeExtensionPackName(name: string) { + // Lowercase everything + name = name.toLowerCase(); + + // Replace all spaces, dots, and underscores with hyphens + name = name.replaceAll(/[\s._]+/g, "-"); + + // Replace all characters which are not allowed by empty strings + name = name.replaceAll(/[^a-z0-9-]/g, ""); + + // Remove any leading or trailing hyphens + name = name.replaceAll(/^-|-$/g, ""); + + // Remove any duplicate hyphens + name = name.replaceAll(/-{2,}/g, "-"); + + return name; +} + +export function parsePackName(packName: string): ExtensionPackName | undefined { + const matches = packNameRegex.exec(packName); + if (!matches?.groups) { + return; + } + + const scope = matches.groups.scope; + const name = matches.groups.name; + + return { + scope, + name, + }; +} + +export function validatePackName(name: string): string | undefined { + if (!name) { + return "Pack name must not be empty"; + } + + if (name.length > packNameLength) { + return `Pack name must be no longer than ${packNameLength} characters`; + } + + const matches = packNameRegex.exec(name); + if (!matches?.groups) { + if (!name.includes("/")) { + return "Invalid package name: a pack name must contain a slash to separate the scope from the pack name"; + } + + return "Invalid package name: a pack name must contain only lowercase ASCII letters, ASCII digits, and hyphens"; + } + + return undefined; +} diff --git a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts index d2f8dca7be4..4ca74e06570 100644 --- a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts +++ b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts @@ -16,15 +16,16 @@ import { ExtensionPack, ExtensionPackModelFile } from "./shared/extension-pack"; import { NotificationLogger, showAndLogErrorMessage } from "../common/logging"; import { containsPath } from "../pure/files"; import { disableAutoNameExtensionPack } from "../config"; +import { + autoNameExtensionPack, + ExtensionPackName, + formatPackName, + parsePackName, + validatePackName, +} from "./extension-pack-name"; const maxStep = 3; -const packNamePartRegex = /[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/; -const packNameRegex = new RegExp( - `^(?${packNamePartRegex.source})/(?${packNamePartRegex.source})$`, -); -const packNameLength = 128; - export async function pickExtensionPackModelFile( cliServer: Pick, databaseItem: Pick, @@ -265,30 +266,25 @@ async function pickNewExtensionPack( databaseItem.language, ); - const packName = await window.showInputBox( + const name = await window.showInputBox( { title: "Create new extension pack", prompt: "Enter name of extension pack", - placeHolder: `e.g. ${examplePackName}`, + placeHolder: examplePackName + ? `e.g. ${formatPackName(examplePackName)}` + : "", validateInput: async (value: string): Promise => { - if (!value) { - return "Pack name must not be empty"; - } - - if (value.length > packNameLength) { - return `Pack name must be no longer than ${packNameLength} characters`; + const message = validatePackName(value); + if (message) { + return message; } - const matches = packNameRegex.exec(value); - if (!matches?.groups) { - if (!value.includes("/")) { - return "Invalid package name: a pack name must contain a slash to separate the scope from the pack name"; - } - - return "Invalid package name: a pack name must contain only lowercase ASCII letters, ASCII digits, and hyphens"; + const packName = parsePackName(value); + if (!packName) { + return "Invalid pack name"; } - const packPath = join(workspaceFolder.uri.fsPath, matches.groups.name); + const packPath = join(workspaceFolder.uri.fsPath, packName.name); if (await pathExists(packPath)) { return `A pack already exists at ${packPath}`; } @@ -298,17 +294,16 @@ async function pickNewExtensionPack( }, token, ); - if (!packName) { + if (!name) { return undefined; } - const matches = packNameRegex.exec(packName); - if (!matches?.groups) { - return; + const packName = parsePackName(name); + if (!packName) { + return undefined; } - const name = matches.groups.name; - const packPath = join(workspaceFolder.uri.fsPath, name); + const packPath = join(workspaceFolder.uri.fsPath, packName.name); if (await pathExists(packPath)) { return undefined; @@ -338,7 +333,8 @@ async function autoCreateExtensionPack( return undefined; } - const existingExtensionPackPaths = extensionPacksInfo[packName]; + const existingExtensionPackPaths = + extensionPacksInfo[formatPackName(packName)]; // If there is already an extension pack with this name, use it if it is valid if (existingExtensionPackPaths?.length === 1) { let extensionPack: ExtensionPack; @@ -347,11 +343,11 @@ async function autoCreateExtensionPack( } catch (e: unknown) { void showAndLogErrorMessage( logger, - `Could not read extension pack ${packName}`, + `Could not read extension pack ${formatPackName(packName)}`, { - fullMessage: `Could not read extension pack ${packName} at ${ - existingExtensionPackPaths[0] - }: ${getErrorMessage(e)}`, + fullMessage: `Could not read extension pack ${formatPackName( + packName, + )} at ${existingExtensionPackPaths[0]}: ${getErrorMessage(e)}`, }, ); @@ -366,9 +362,11 @@ async function autoCreateExtensionPack( if (existingExtensionPackPaths?.length > 1) { void showAndLogErrorMessage( logger, - `Extension pack ${packName} resolves to multiple paths`, + `Extension pack ${formatPackName(packName)} resolves to multiple paths`, { - fullMessage: `Extension pack ${packName} resolves to multiple paths: ${existingExtensionPackPaths.join( + fullMessage: `Extension pack ${formatPackName( + packName, + )} resolves to multiple paths: ${existingExtensionPackPaths.join( ", ", )}`, }, @@ -377,23 +375,14 @@ async function autoCreateExtensionPack( return undefined; } - const matches = packNameRegex.exec(packName); - if (!matches?.groups) { - void showAndLogErrorMessage( - logger, - `Extension pack ${packName} does not have a valid name`, - ); - - return undefined; - } - - const unscopedName = matches.groups.name; - const packPath = join(workspaceFolder.uri.fsPath, unscopedName); + const packPath = join(workspaceFolder.uri.fsPath, packName.name); if (await pathExists(packPath)) { void showAndLogErrorMessage( logger, - `Directory ${packPath} already exists for extension pack ${packName}`, + `Directory ${packPath} already exists for extension pack ${formatPackName( + packName, + )}`, ); return undefined; @@ -449,7 +438,7 @@ async function askForWorkspaceFolder(): Promise { async function writeExtensionPack( packPath: string, - packName: string, + packName: ExtensionPackName, language: string, ): Promise { const packYamlPath = join(packPath, "codeql-pack.yml"); @@ -457,7 +446,7 @@ async function writeExtensionPack( const extensionPack: ExtensionPack = { path: packPath, yamlPath: packYamlPath, - name: packName, + name: formatPackName(packName), version: "0.0.0", extensionTargets: { [`codeql/${language}-all`]: "*", @@ -563,40 +552,3 @@ async function readExtensionPack(path: string): Promise { dataExtensions, }; } - -function autoNameExtensionPack( - name: string, - language: string, -): string | undefined { - let packName = `${name}-${language}`; - if (!packName.includes("/")) { - packName = `pack/${packName}`; - } - - const parts = packName.split("/"); - const sanitizedParts = parts.map((part) => sanitizeExtensionPackName(part)); - - // This will ensure there's only 1 slash - packName = `${sanitizedParts[0]}/${sanitizedParts.slice(1).join("-")}`; - - return packName; -} - -function sanitizeExtensionPackName(name: string) { - // Lowercase everything - name = name.toLowerCase(); - - // Replace all spaces, dots, and underscores with hyphens - name = name.replaceAll(/[\s._]+/g, "-"); - - // Replace all characters which are not allowed by empty strings - name = name.replaceAll(/[^a-z0-9-]/g, ""); - - // Remove any leading or trailing hyphens - name = name.replaceAll(/^-|-$/g, ""); - - // Remove any duplicate hyphens - name = name.replaceAll(/-{2,}/g, "-"); - - return name; -} diff --git a/extensions/ql-vscode/test/unit-tests/data-extensions-editor/extension-pack-name.test.ts b/extensions/ql-vscode/test/unit-tests/data-extensions-editor/extension-pack-name.test.ts new file mode 100644 index 00000000000..be30c54ca8e --- /dev/null +++ b/extensions/ql-vscode/test/unit-tests/data-extensions-editor/extension-pack-name.test.ts @@ -0,0 +1,53 @@ +import { + autoNameExtensionPack, + formatPackName, + parsePackName, + validatePackName, +} from "../../../src/data-extensions-editor/extension-pack-name"; + +describe("autoNameExtensionPack", () => { + const testCases: Array<{ + name: string; + language: string; + expected: string; + }> = [ + { + name: "github/vscode-codeql", + language: "javascript", + expected: "github/vscode-codeql-javascript", + }, + { + name: "vscode-codeql", + language: "a", + expected: "pack/vscode-codeql-a", + }, + { + name: "b", + language: "java", + expected: "pack/b-java", + }, + { + name: "a/b", + language: "csharp", + expected: "a/b-csharp", + }, + { + name: "-/b", + language: "csharp", + expected: "pack/b-csharp", + }, + ]; + + test.each(testCases)( + "$name with $language = $expected", + ({ name, language, expected }) => { + const result = autoNameExtensionPack(name, language); + expect(result).not.toBeUndefined(); + if (!result) { + return; + } + expect(validatePackName(formatPackName(result))).toBeUndefined(); + expect(result).toEqual(parsePackName(expected)); + }, + ); +}); From 3323fd4e3bd4f2cba5755563d3a74277ab5fcc3d Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Mon, 19 Jun 2023 13:51:24 +0200 Subject: [PATCH 05/11] Add more tests for auto pack naming --- .../extension-pack-name.ts | 5 +++ .../extension-pack-name.test.ts | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-name.ts b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-name.ts index d148dc1a297..cbd331f5f2e 100644 --- a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-name.ts +++ b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-name.ts @@ -25,6 +25,11 @@ export function autoNameExtensionPack( const parts = packName.split("/"); const sanitizedParts = parts.map((part) => sanitizeExtensionPackName(part)); + // If the scope is empty (e.g. if the given name is "-/b"), then we need to still set a scope + if (sanitizedParts[0].length === 0) { + sanitizedParts[0] = "pack"; + } + return { scope: sanitizedParts[0], // This will ensure there's only 1 slash diff --git a/extensions/ql-vscode/test/unit-tests/data-extensions-editor/extension-pack-name.test.ts b/extensions/ql-vscode/test/unit-tests/data-extensions-editor/extension-pack-name.test.ts index be30c54ca8e..6498f82068d 100644 --- a/extensions/ql-vscode/test/unit-tests/data-extensions-editor/extension-pack-name.test.ts +++ b/extensions/ql-vscode/test/unit-tests/data-extensions-editor/extension-pack-name.test.ts @@ -36,6 +36,41 @@ describe("autoNameExtensionPack", () => { language: "csharp", expected: "pack/b-csharp", }, + { + name: "a/b/c/d", + language: "csharp", + expected: "a/b-c-d-csharp", + }, + { + name: "JAVA/CodeQL", + language: "csharp", + expected: "java/codeql-csharp", + }, + { + name: "my new pack", + language: "swift", + expected: "pack/my-new-pack-swift", + }, + { + name: "gïthub/vscode-codeql", + language: "javascript", + expected: "gthub/vscode-codeql-javascript", + }, + { + name: "a/b-", + language: "csharp", + expected: "a/b-csharp", + }, + { + name: "-a-/b", + language: "ruby", + expected: "a/b-ruby", + }, + { + name: "a/b--d--e-d-", + language: "csharp", + expected: "a/b-d-e-d-csharp", + }, ]; test.each(testCases)( From fe29a1a32a9a2b6aaa5210c1966daa97109fd99c Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Mon, 19 Jun 2023 13:54:17 +0200 Subject: [PATCH 06/11] Add more comments --- .../data-extensions-editor/extension-pack-picker.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts index 4ca74e06570..fe82fad143a 100644 --- a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts +++ b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts @@ -318,11 +318,13 @@ async function autoCreateExtensionPack( extensionPacksInfo: QlpacksInfo, logger: NotificationLogger, ): Promise { + // Choose a workspace folder to create the extension pack in const workspaceFolder = await autoPickWorkspaceFolder(language); if (!workspaceFolder) { return undefined; } + // Generate the name of the extension pack const packName = autoNameExtensionPack(name, language); if (!packName) { void showAndLogErrorMessage( @@ -333,8 +335,10 @@ async function autoCreateExtensionPack( return undefined; } + // Find any existing locations of this extension pack const existingExtensionPackPaths = extensionPacksInfo[formatPackName(packName)]; + // If there is already an extension pack with this name, use it if it is valid if (existingExtensionPackPaths?.length === 1) { let extensionPack: ExtensionPack; @@ -396,9 +400,13 @@ async function autoPickWorkspaceFolder( ): Promise { const workspaceFolders = getOnDiskWorkspaceFoldersObjects(); + // If there's only 1 workspace folder, use that if (workspaceFolders.length === 1) { return workspaceFolders[0]; } + + // In the vscode-codeql-starter repository, all workspace folders are named "codeql-custom-queries-", + // so we can use that to find the workspace folder for the language const starterWorkspaceFolderForLanguage = workspaceFolders.find( (folder) => folder.name === `codeql-custom-queries-${language}`, ); @@ -406,6 +414,7 @@ async function autoPickWorkspaceFolder( return starterWorkspaceFolderForLanguage; } + // Otherwise, try to find one that ends with "-" const workspaceFolderForLanguage = workspaceFolders.find((folder) => folder.name.endsWith(`-${language}`), ); @@ -413,6 +422,7 @@ async function autoPickWorkspaceFolder( return workspaceFolderForLanguage; } + // If we can't find one, just ask the user return askForWorkspaceFolder(); } From ab6db717279cb9526896a19fe69476bcadc0526f Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Tue, 20 Jun 2023 10:06:52 +0200 Subject: [PATCH 07/11] Move workspace folder functions to separate file --- .../extension-pack-picker.ts | 62 ++----------------- .../extensions-workspace-folder.ts | 55 ++++++++++++++++ 2 files changed, 61 insertions(+), 56 deletions(-) create mode 100644 extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts diff --git a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts index fe82fad143a..4cb059c28a9 100644 --- a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts +++ b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts @@ -2,12 +2,9 @@ import { join, relative, resolve, sep } from "path"; import { outputFile, pathExists, readFile } from "fs-extra"; import { dump as dumpYaml, load as loadYaml } from "js-yaml"; import { minimatch } from "minimatch"; -import { CancellationToken, window, WorkspaceFolder } from "vscode"; +import { CancellationToken, window } from "vscode"; import { CodeQLCliServer, QlpacksInfo } from "../codeql-cli/cli"; -import { - getOnDiskWorkspaceFolders, - getOnDiskWorkspaceFoldersObjects, -} from "../common/vscode/workspace-folders"; +import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; import { ProgressCallback } from "../common/vscode/progress"; import { DatabaseItem } from "../databases/local-databases"; import { getQlPackPath, QLPACK_FILENAMES } from "../pure/ql"; @@ -23,6 +20,10 @@ import { parsePackName, validatePackName, } from "./extension-pack-name"; +import { + askForWorkspaceFolder, + autoPickWorkspaceFolder, +} from "./extensions-workspace-folder"; const maxStep = 3; @@ -395,57 +396,6 @@ async function autoCreateExtensionPack( return writeExtensionPack(packPath, packName, language); } -async function autoPickWorkspaceFolder( - language: string, -): Promise { - const workspaceFolders = getOnDiskWorkspaceFoldersObjects(); - - // If there's only 1 workspace folder, use that - if (workspaceFolders.length === 1) { - return workspaceFolders[0]; - } - - // In the vscode-codeql-starter repository, all workspace folders are named "codeql-custom-queries-", - // so we can use that to find the workspace folder for the language - const starterWorkspaceFolderForLanguage = workspaceFolders.find( - (folder) => folder.name === `codeql-custom-queries-${language}`, - ); - if (starterWorkspaceFolderForLanguage) { - return starterWorkspaceFolderForLanguage; - } - - // Otherwise, try to find one that ends with "-" - const workspaceFolderForLanguage = workspaceFolders.find((folder) => - folder.name.endsWith(`-${language}`), - ); - if (workspaceFolderForLanguage) { - return workspaceFolderForLanguage; - } - - // If we can't find one, just ask the user - return askForWorkspaceFolder(); -} - -async function askForWorkspaceFolder(): Promise { - const workspaceFolders = getOnDiskWorkspaceFoldersObjects(); - const workspaceFolderOptions = workspaceFolders.map((folder) => ({ - label: folder.name, - detail: folder.uri.fsPath, - folder, - })); - - // We're not using window.showWorkspaceFolderPick because that also includes the database source folders while - // we only want to include on-disk workspace folders. - const workspaceFolder = await window.showQuickPick(workspaceFolderOptions, { - title: "Select workspace folder to create extension pack in", - }); - if (!workspaceFolder) { - return undefined; - } - - return workspaceFolder.folder; -} - async function writeExtensionPack( packPath: string, packName: ExtensionPackName, diff --git a/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts b/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts new file mode 100644 index 00000000000..9b6d322bee5 --- /dev/null +++ b/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts @@ -0,0 +1,55 @@ +import { window, WorkspaceFolder } from "vscode"; +import { getOnDiskWorkspaceFoldersObjects } from "../common/vscode/workspace-folders"; + +export async function autoPickWorkspaceFolder( + language: string, +): Promise { + const workspaceFolders = getOnDiskWorkspaceFoldersObjects(); + + // If there's only 1 workspace folder, use that + if (workspaceFolders.length === 1) { + return workspaceFolders[0]; + } + + // In the vscode-codeql-starter repository, all workspace folders are named "codeql-custom-queries-", + // so we can use that to find the workspace folder for the language + const starterWorkspaceFolderForLanguage = workspaceFolders.find( + (folder) => folder.name === `codeql-custom-queries-${language}`, + ); + if (starterWorkspaceFolderForLanguage) { + return starterWorkspaceFolderForLanguage; + } + + // Otherwise, try to find one that ends with "-" + const workspaceFolderForLanguage = workspaceFolders.find((folder) => + folder.name.endsWith(`-${language}`), + ); + if (workspaceFolderForLanguage) { + return workspaceFolderForLanguage; + } + + // If we can't find one, just ask the user + return askForWorkspaceFolder(); +} + +export async function askForWorkspaceFolder(): Promise< + WorkspaceFolder | undefined +> { + const workspaceFolders = getOnDiskWorkspaceFoldersObjects(); + const workspaceFolderOptions = workspaceFolders.map((folder) => ({ + label: folder.name, + detail: folder.uri.fsPath, + folder, + })); + + // We're not using window.showWorkspaceFolderPick because that also includes the database source folders while + // we only want to include on-disk workspace folders. + const workspaceFolder = await window.showQuickPick(workspaceFolderOptions, { + title: "Select workspace folder to create extension pack in", + }); + if (!workspaceFolder) { + return undefined; + } + + return workspaceFolder.folder; +} From cfc66a4e17942d85d82d6090cd0e746c79b25551 Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Tue, 20 Jun 2023 10:58:19 +0200 Subject: [PATCH 08/11] Store extension packs in `.github/codeql/extensions` --- .../extension-pack-picker.ts | 10 +- .../extensions-workspace-folder.ts | 126 +++++++++++--- .../extension-pack-picker.test.ts | 18 +- .../extensions-workspace-folder.test.ts | 156 ++++++++++++++++++ 4 files changed, 283 insertions(+), 27 deletions(-) create mode 100644 extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extensions-workspace-folder.test.ts diff --git a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts index 4cb059c28a9..97cae02a360 100644 --- a/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts +++ b/extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts @@ -22,7 +22,7 @@ import { } from "./extension-pack-name"; import { askForWorkspaceFolder, - autoPickWorkspaceFolder, + autoPickExtensionsDirectory, } from "./extensions-workspace-folder"; const maxStep = 3; @@ -319,9 +319,9 @@ async function autoCreateExtensionPack( extensionPacksInfo: QlpacksInfo, logger: NotificationLogger, ): Promise { - // Choose a workspace folder to create the extension pack in - const workspaceFolder = await autoPickWorkspaceFolder(language); - if (!workspaceFolder) { + // Get the extensions directory to create the extension pack in + const extensionsDirectory = await autoPickExtensionsDirectory(); + if (!extensionsDirectory) { return undefined; } @@ -380,7 +380,7 @@ async function autoCreateExtensionPack( return undefined; } - const packPath = join(workspaceFolder.uri.fsPath, packName.name); + const packPath = join(extensionsDirectory.fsPath, packName.name); if (await pathExists(packPath)) { void showAndLogErrorMessage( diff --git a/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts b/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts index 9b6d322bee5..2571464a4f6 100644 --- a/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts +++ b/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts @@ -1,35 +1,123 @@ -import { window, WorkspaceFolder } from "vscode"; +import { Uri, window, workspace, WorkspaceFolder } from "vscode"; import { getOnDiskWorkspaceFoldersObjects } from "../common/vscode/workspace-folders"; +import { extLogger } from "../common"; + +/** + * Returns the ancestors of this path in order from furthest to closest (i.e. root of filesystem to parent directory) + */ +function getAncestors(uri: Uri): Uri[] { + const ancestors: Uri[] = []; + let current = uri; + while (current.fsPath !== Uri.joinPath(current, "..").fsPath) { + ancestors.push(current); + current = Uri.joinPath(current, ".."); + } + + // The ancestors are now in order from closest to furthest, so reverse them + ancestors.reverse(); + + return ancestors; +} + +function getRootWorkspaceDirectory(): Uri | undefined { + // 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, ".."); + } -export async function autoPickWorkspaceFolder( - language: string, -): Promise { const workspaceFolders = getOnDiskWorkspaceFoldersObjects(); - // If there's only 1 workspace folder, use that + // 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 ancestors = getAncestors(folderUri); + + const minLength = Math.min(commonRoot.length, ancestors.length); + let commonLength = 0; + for (let i = 0; i < minLength; i++) { + if (commonRoot[i].fsPath === ancestors[i].fsPath) { + commonLength++; + } else { + break; + } + } + + return commonRoot.slice(0, commonLength); + }, getAncestors(workspaceFolders[0].uri)); + + if (commonRoot.length === 0) { + return undefined; + } + + // The path closest to the workspace folders is the last element of the common root + const commonRootUri = commonRoot[commonRoot.length - 1]; + + // 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; +} + +export async function autoPickExtensionsDirectory(): Promise { + const workspaceFolders = getOnDiskWorkspaceFoldersObjects(); + + // If there's only 1 workspace folder, use the `.github/codeql/extensions` directory in that folder if (workspaceFolders.length === 1) { - return workspaceFolders[0]; + return Uri.joinPath( + workspaceFolders[0].uri, + ".github", + "codeql", + "extensions", + ); } - // In the vscode-codeql-starter repository, all workspace folders are named "codeql-custom-queries-", - // so we can use that to find the workspace folder for the language - const starterWorkspaceFolderForLanguage = workspaceFolders.find( - (folder) => folder.name === `codeql-custom-queries-${language}`, + // 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 (starterWorkspaceFolderForLanguage) { - return starterWorkspaceFolderForLanguage; + if (workspaceFolderForExtensions) { + return workspaceFolderForExtensions.uri; } - // Otherwise, try to find one that ends with "-" - const workspaceFolderForLanguage = workspaceFolders.find((folder) => - folder.name.endsWith(`-${language}`), + // Get the root workspace directory, i.e. the common root directory of all workspace folders + const rootDirectory = getRootWorkspaceDirectory(); + if (!rootDirectory) { + void extLogger.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 (workspaceFolderForLanguage) { - return workspaceFolderForLanguage; + + if ( + !workspace.updateWorkspaceFolders( + workspace.workspaceFolders?.length ?? 0, + 0, + { + name: "CodeQL Extension Packs", + uri: extensionsUri, + }, + ) + ) { + void extLogger.log( + `Failed to add workspace folder for extensions at ${extensionsUri.fsPath}`, + ); + return undefined; } - // If we can't find one, just ask the user - return askForWorkspaceFolder(); + return extensionsUri; } export async function askForWorkspaceFolder(): Promise< diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extension-pack-picker.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extension-pack-picker.test.ts index 229192a4b4c..4c250ccac81 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extension-pack-picker.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extension-pack-picker.test.ts @@ -328,13 +328,25 @@ describe("pickExtensionPackModelFile", () => { index: 1, }, { - uri: Uri.file(tmpDir.path), + uri: Uri.joinPath(Uri.file(tmpDir.path), "codeql-custom-queries-java"), name: "codeql-custom-queries-java", index: 2, }, ]); - - const newPackDir = join(Uri.file(tmpDir.path).fsPath, "vscode-codeql-java"); + 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, + ".github", + "codeql", + "extensions", + "vscode-codeql-java", + ); const cliServer = mockCliServer({}, { models: [], data: {} }); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extensions-workspace-folder.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extensions-workspace-folder.test.ts new file mode 100644 index 00000000000..00e0b2d0cbe --- /dev/null +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extensions-workspace-folder.test.ts @@ -0,0 +1,156 @@ +import { Uri, workspace, WorkspaceFolder } from "vscode"; +import { dir, DirectoryResult } from "tmp-promise"; +import { autoPickExtensionsDirectory } from "../../../../src/data-extensions-editor/extensions-workspace-folder"; + +describe("autoPickExtensionsDirectory", () => { + 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 + >; + + beforeEach(async () => { + tmpDir = await dir({ + unsafeCleanup: true, + }); + + rootDirectory = Uri.file(tmpDir.path); + extensionsDirectory = Uri.joinPath( + rootDirectory, + ".github", + "codeql", + "extensions", + ); + + workspaceFoldersSpy = jest + .spyOn(workspace, "workspaceFolders", "get") + .mockReturnValue([]); + workspaceFileSpy = jest + .spyOn(workspace, "workspaceFile", "get") + .mockReturnValue(undefined); + updateWorkspaceFoldersSpy = jest + .spyOn(workspace, "updateWorkspaceFolders") + .mockReturnValue(true); + }); + + 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()).toEqual(extensionsDirectory); + expect(updateWorkspaceFoldersSpy).not.toHaveBeenCalled(); + }); + + it("when a workspace file exists", 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, + }, + ]); + + workspaceFileSpy.mockReturnValue( + Uri.joinPath(rootDirectory, "workspace.code-workspace"), + ); + + expect(await autoPickExtensionsDirectory()).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()).toEqual(undefined); + }); + + it("when a workspace file does not exist and there is a common root directory", 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, + }, + ]); + + expect(await autoPickExtensionsDirectory()).toEqual(extensionsDirectory); + expect(updateWorkspaceFoldersSpy).toHaveBeenCalledWith(2, 0, { + name: "CodeQL Extension Packs", + uri: extensionsDirectory, + }); + }); + + it("when a workspace file does not exist and there is no common root directory", async () => { + workspaceFoldersSpy.mockReturnValue([ + { + uri: Uri.joinPath(rootDirectory, "codeql-custom-queries-java"), + name: "codeql-custom-queries-java", + index: 0, + }, + { + uri: Uri.file("/a/b/c"), + name: "codeql-custom-queries-python", + index: 1, + }, + ]); + + expect(await autoPickExtensionsDirectory()).toEqual(undefined); + expect(updateWorkspaceFoldersSpy).not.toHaveBeenCalled(); + }); +}); From f32a240e24f5fb20706b44e46e83360b36badd5d Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Wed, 21 Jun 2023 09:44:39 +0200 Subject: [PATCH 09/11] Exclude workspace folders in the system temp dir --- .../extensions-workspace-folder.ts | 11 +++++- extensions/ql-vscode/src/pure/files.ts | 6 ++++ .../extensions-workspace-folder.test.ts | 36 ++++++++++++++++++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts b/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts index 2571464a4f6..6960ab163da 100644 --- a/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts +++ b/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts @@ -1,6 +1,7 @@ import { Uri, window, workspace, WorkspaceFolder } from "vscode"; import { getOnDiskWorkspaceFoldersObjects } from "../common/vscode/workspace-folders"; import { extLogger } from "../common"; +import { tmpdir } from "../pure/files"; /** * Returns the ancestors of this path in order from furthest to closest (i.e. root of filesystem to parent directory) @@ -26,7 +27,15 @@ function getRootWorkspaceDirectory(): Uri | undefined { return Uri.joinPath(workspaceFile, ".."); } - const workspaceFolders = getOnDiskWorkspaceFoldersObjects(); + 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); + }); // Find the common root directory of all workspace folders by finding the longest common prefix const commonRoot = workspaceFolders.reduce((commonRoot, folder) => { diff --git a/extensions/ql-vscode/src/pure/files.ts b/extensions/ql-vscode/src/pure/files.ts index 5460231107a..e59e85e4a12 100644 --- a/extensions/ql-vscode/src/pure/files.ts +++ b/extensions/ql-vscode/src/pure/files.ts @@ -1,5 +1,6 @@ import { pathExists, stat, readdir, opendir } from "fs-extra"; import { isAbsolute, join, relative, resolve } from "path"; +import { tmpdir as osTmpdir } from "os"; /** * Recursively finds all .ql files in this set of Uris. @@ -121,3 +122,8 @@ export interface IOError { export function isIOError(e: any): e is IOError { return e.code !== undefined && typeof e.code === "string"; } + +// This function is a wrapper around `os.tmpdir()` to make it easier to mock in tests. +export function tmpdir(): string { + return osTmpdir(); +} diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extensions-workspace-folder.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extensions-workspace-folder.test.ts index 00e0b2d0cbe..b6e7cae59fb 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extensions-workspace-folder.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extensions-workspace-folder.test.ts @@ -1,6 +1,8 @@ import { Uri, workspace, WorkspaceFolder } from "vscode"; import { dir, DirectoryResult } from "tmp-promise"; +import { join } from "path"; import { autoPickExtensionsDirectory } from "../../../../src/data-extensions-editor/extensions-workspace-folder"; +import * as files from "../../../../src/pure/files"; describe("autoPickExtensionsDirectory", () => { let tmpDir: DirectoryResult; @@ -15,13 +17,14 @@ describe("autoPickExtensionsDirectory", () => { let updateWorkspaceFoldersSpy: jest.SpiedFunction< typeof workspace.updateWorkspaceFolders >; + let mockedTmpDirUri: Uri; beforeEach(async () => { tmpDir = await dir({ unsafeCleanup: true, }); - rootDirectory = Uri.file(tmpDir.path); + rootDirectory = Uri.joinPath(Uri.file(tmpDir.path), "root"); extensionsDirectory = Uri.joinPath( rootDirectory, ".github", @@ -29,6 +32,9 @@ describe("autoPickExtensionsDirectory", () => { "extensions", ); + const mockedTmpDir = join(tmpDir.path, ".tmp", "tmp"); + mockedTmpDirUri = Uri.file(mockedTmpDir); + workspaceFoldersSpy = jest .spyOn(workspace, "workspaceFolders", "get") .mockReturnValue([]); @@ -38,6 +44,8 @@ describe("autoPickExtensionsDirectory", () => { updateWorkspaceFoldersSpy = jest .spyOn(workspace, "updateWorkspaceFolders") .mockReturnValue(true); + + jest.spyOn(files, "tmpdir").mockReturnValue(mockedTmpDir); }); afterEach(async () => { @@ -136,6 +144,32 @@ describe("autoPickExtensionsDirectory", () => { }); }); + it("when a workspace file does not exist and there is a temp dir as workspace folder", 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: Uri.joinPath(mockedTmpDirUri, "quick-queries"), + name: "quick-queries", + index: 2, + }, + ]); + + expect(await autoPickExtensionsDirectory()).toEqual(extensionsDirectory); + 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 () => { workspaceFoldersSpy.mockReturnValue([ { From d092e69abfe4708f0513f4e6534a9f1ffa320a04 Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Wed, 21 Jun 2023 09:55:29 +0200 Subject: [PATCH 10/11] Add detection of root workspace directory using .git folder --- .../extensions-workspace-folder.ts | 63 +++++++++++++++++-- .../extensions-workspace-folder.test.ts | 24 +++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts b/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts index 6960ab163da..422329203ed 100644 --- a/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts +++ b/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts @@ -1,4 +1,4 @@ -import { Uri, window, workspace, WorkspaceFolder } from "vscode"; +import { FileType, Uri, window, workspace, WorkspaceFolder } from "vscode"; import { getOnDiskWorkspaceFoldersObjects } from "../common/vscode/workspace-folders"; import { extLogger } from "../common"; import { tmpdir } from "../pure/files"; @@ -20,7 +20,7 @@ function getAncestors(uri: Uri): Uri[] { return ancestors; } -function getRootWorkspaceDirectory(): Uri | undefined { +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") { @@ -56,7 +56,7 @@ function getRootWorkspaceDirectory(): Uri | undefined { }, getAncestors(workspaceFolders[0].uri)); if (commonRoot.length === 0) { - return undefined; + return await findGitFolder(workspaceFolders); } // The path closest to the workspace folders is the last element of the common root @@ -65,12 +65,65 @@ function getRootWorkspaceDirectory(): Uri | undefined { // 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 await findGitFolder(workspaceFolders); } return commonRootUri; } +async function findGitFolder( + workspaceFolders: WorkspaceFolder[], +): Promise { + // Go through all workspace folders one-by-one and try to find the closest .git folder for each one + const folders = await Promise.all( + workspaceFolders.map(async (folder) => { + const ancestors = getAncestors(folder.uri); + + // Reverse the ancestors so we're going from closest to furthest + ancestors.reverse(); + + const gitFoldersExists = await Promise.all( + ancestors.map(async (uri) => { + const gitFolder = Uri.joinPath(uri, ".git"); + try { + const stat = await workspace.fs.stat(gitFolder); + // Check whether it's a directory + return (stat.type & FileType.Directory) !== 0; + } catch (e) { + return false; + } + }), + ); + + // Find the first ancestor that has a .git folder + const ancestorIndex = gitFoldersExists.findIndex((exists) => exists); + + if (ancestorIndex === -1) { + return undefined; + } + + return [ancestorIndex, ancestors[ancestorIndex]]; + }), + ); + + const validFolders = folders.filter( + (folder): folder is [number, Uri] => folder !== undefined, + ); + if (validFolders.length === 0) { + return undefined; + } + + // Find the .git folder which is closest to a workspace folder + const closestFolder = validFolders.reduce((closestFolder, folder) => { + if (folder[0] < closestFolder[0]) { + return folder; + } + return closestFolder; + }, validFolders[0]); + + return closestFolder?.[1]; +} + export async function autoPickExtensionsDirectory(): Promise { const workspaceFolders = getOnDiskWorkspaceFoldersObjects(); @@ -94,7 +147,7 @@ export async function autoPickExtensionsDirectory(): Promise { } // Get the root workspace directory, i.e. the common root directory of all workspace folders - const rootDirectory = getRootWorkspaceDirectory(); + const rootDirectory = await getRootWorkspaceDirectory(); if (!rootDirectory) { void extLogger.log("Unable to determine root workspace directory"); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extensions-workspace-folder.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extensions-workspace-folder.test.ts index b6e7cae59fb..bffa1d65999 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extensions-workspace-folder.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/data-extensions-editor/extensions-workspace-folder.test.ts @@ -3,6 +3,7 @@ import { dir, DirectoryResult } from "tmp-promise"; import { join } from "path"; import { autoPickExtensionsDirectory } from "../../../../src/data-extensions-editor/extensions-workspace-folder"; import * as files from "../../../../src/pure/files"; +import { mkdirp } from "fs-extra"; describe("autoPickExtensionsDirectory", () => { let tmpDir: DirectoryResult; @@ -187,4 +188,27 @@ describe("autoPickExtensionsDirectory", () => { expect(await autoPickExtensionsDirectory()).toEqual(undefined); expect(updateWorkspaceFoldersSpy).not.toHaveBeenCalled(); }); + + it("when a workspace file does not exist and there is a .git folder", async () => { + await mkdirp(join(rootDirectory.fsPath, ".git")); + + workspaceFoldersSpy.mockReturnValue([ + { + uri: Uri.joinPath(rootDirectory, "codeql-custom-queries-java"), + name: "codeql-custom-queries-java", + index: 0, + }, + { + uri: Uri.file("/a/b/c"), + name: "codeql-custom-queries-python", + index: 1, + }, + ]); + + expect(await autoPickExtensionsDirectory()).toEqual(extensionsDirectory); + expect(updateWorkspaceFoldersSpy).toHaveBeenCalledWith(2, 0, { + name: "CodeQL Extension Packs", + uri: extensionsDirectory, + }); + }); }); From 7249f4c343d8e0ad74ea6f34ab8b15cdde51680e Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Thu, 22 Jun 2023 10:08:11 +0200 Subject: [PATCH 11/11] Add comment to explain heuristic --- .../extensions-workspace-folder.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts b/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts index 484fa11fad2..ef09aa161dd 100644 --- a/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts +++ b/extensions/ql-vscode/src/data-extensions-editor/extensions-workspace-folder.ts @@ -124,6 +124,25 @@ 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(): Promise { const workspaceFolders = getOnDiskWorkspaceFoldersObjects();