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 db71eb42e9e..df47932a45a 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 @@ -1,23 +1,38 @@ -import { relative, resolve, sep } from "path"; -import { pathExists, readFile } from "fs-extra"; -import { load as loadYaml } from "js-yaml"; +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 "../cli"; -import { getOnDiskWorkspaceFolders, showAndLogErrorMessage } from "../helpers"; +import { + getOnDiskWorkspaceFolders, + getOnDiskWorkspaceFoldersObjects, + showAndLogErrorMessage, +} from "../helpers"; import { ProgressCallback } from "../progress"; import { DatabaseItem } from "../local-databases"; import { getQlPackPath, QLPACK_FILENAMES } from "../pure/ql"; 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, progress: ProgressCallback, token: CancellationToken, ): Promise { - const extensionPackPath = await pickExtensionPack(cliServer, progress, token); + const extensionPackPath = await pickExtensionPack( + cliServer, + databaseItem, + progress, + token, + ); if (!extensionPackPath) { return; } @@ -38,6 +53,7 @@ export async function pickExtensionPackModelFile( async function pickExtensionPack( cliServer: Pick, + databaseItem: Pick, progress: ProgressCallback, token: CancellationToken, ): Promise { @@ -50,10 +66,20 @@ async function pickExtensionPack( // Get all existing extension packs in the workspace const additionalPacks = getOnDiskWorkspaceFolders(); const extensionPacks = await cliServer.resolveQlpacks(additionalPacks, true); - const options = Object.keys(extensionPacks).map((pack) => ({ - label: pack, - extensionPack: pack, - })); + + if (Object.keys(extensionPacks).length === 0) { + return pickNewExtensionPack(databaseItem, token); + } + + const options: Array<{ label: string; extensionPack: string | null }> = + Object.keys(extensionPacks).map((pack) => ({ + label: pack, + extensionPack: pack, + })); + options.push({ + label: "Create new extension pack", + extensionPack: null, + }); progress({ message: "Choosing extension pack...", @@ -72,6 +98,10 @@ async function pickExtensionPack( return undefined; } + if (!extensionPackOption.extensionPack) { + return pickNewExtensionPack(databaseItem, token); + } + const extensionPackPaths = extensionPacks[extensionPackOption.extensionPack]; if (extensionPackPaths.length !== 1) { void showAndLogErrorMessage( @@ -153,6 +183,89 @@ async function pickModelFile( return pickNewModelFile(databaseItem, extensionPackPath, token); } +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", + }); + if (!workspaceFolder) { + return undefined; + } + + const packName = await window.showInputBox( + { + title: "Create new extension pack", + prompt: "Enter name of extension pack", + placeHolder: `e.g. ${databaseItem.name}-extensions`, + 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 matches = packNameRegex.exec(value); + if (!matches?.groups) { + 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); + if (await pathExists(packPath)) { + return `A pack already exists at ${packPath}`; + } + + return undefined; + }, + }, + token, + ); + if (!packName) { + return undefined; + } + + const matches = packNameRegex.exec(packName); + if (!matches?.groups) { + return; + } + + const name = matches.groups.name; + const packPath = join(workspaceFolder.path, name); + + if (await pathExists(packPath)) { + return undefined; + } + + const packYamlPath = join(packPath, "codeql-pack.yml"); + + await outputFile( + packYamlPath, + dumpYaml({ + name, + version: "0.0.0", + library: true, + extensionTargets: { + "codeql/java-all": "*", + }, + dataExtensions: ["models/**/*.yml"], + }), + ); + + return packPath; +} + async function pickNewModelFile( databaseItem: Pick, extensionPackPath: string, diff --git a/extensions/ql-vscode/src/helpers.ts b/extensions/ql-vscode/src/helpers.ts index c8d1839e0aa..f3c56934e86 100644 --- a/extensions/ql-vscode/src/helpers.ts +++ b/extensions/ql-vscode/src/helpers.ts @@ -16,6 +16,7 @@ import { window as Window, workspace, env, + WorkspaceFolder, } from "vscode"; import { CodeQLCliServer, QlpacksInfo } from "./cli"; import { UserCancellationException } from "./progress"; @@ -249,16 +250,21 @@ export async function showInformationMessageWithAction( } /** Gets all active workspace folders that are on the filesystem. */ -export function getOnDiskWorkspaceFolders() { +export function getOnDiskWorkspaceFoldersObjects() { const workspaceFolders = workspace.workspaceFolders || []; - const diskWorkspaceFolders: string[] = []; + const diskWorkspaceFolders: WorkspaceFolder[] = []; for (const workspaceFolder of workspaceFolders) { if (workspaceFolder.uri.scheme === "file") - diskWorkspaceFolders.push(workspaceFolder.uri.fsPath); + diskWorkspaceFolders.push(workspaceFolder); } return diskWorkspaceFolders; } +/** Gets all active workspace folders that are on the filesystem. */ +export function getOnDiskWorkspaceFolders() { + return getOnDiskWorkspaceFoldersObjects().map((folder) => folder.uri.fsPath); +} + /** Check if folder is already present in workspace */ export function isFolderAlreadyInWorkspace(folderName: string) { const workspaceFolders = workspace.workspaceFolders || []; 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 4ca05c20d8b..2846f6c8850 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,6 +1,6 @@ import { CancellationTokenSource, QuickPickItem, window } from "vscode"; -import { dump as dumpYaml } from "js-yaml"; -import { outputFile } from "fs-extra"; +import { dump as dumpYaml, load as loadYaml } from "js-yaml"; +import { outputFile, readFile } from "fs-extra"; import { join } from "path"; import { dir } from "tmp-promise"; @@ -84,6 +84,10 @@ describe("pickExtensionPackModelFile", () => { label: "another-extension-pack", extensionPack: "another-extension-pack", }, + { + label: expect.stringMatching(/create/i), + extensionPack: null, + }, ], { title: expect.any(String), @@ -181,6 +185,10 @@ describe("pickExtensionPackModelFile", () => { label: "another-extension-pack", extensionPack: "another-extension-pack", }, + { + label: expect.stringMatching(/create/i), + extensionPack: null, + }, ], { title: expect.any(String), @@ -234,10 +242,19 @@ describe("pickExtensionPackModelFile", () => { expect(cliServer.resolveExtensions).not.toHaveBeenCalled(); }); - it("does not show any options when there are no extension packs", async () => { + it("allows user to create an extension pack when there are no extension packs", async () => { const cliServer = mockCliServer({}, { models: [], data: {} }); - showQuickPickSpy.mockResolvedValueOnce(undefined); + const tmpDir = await dir({ + unsafeCleanup: true, + }); + + showQuickPickSpy.mockResolvedValueOnce({ + label: "codeql-custom-queries-java", + path: tmpDir.path, + } as QuickPickItem); + showInputBoxSpy.mockResolvedValueOnce("my-extension-pack"); + showInputBoxSpy.mockResolvedValue("models/my-model.yml"); expect( await pickExtensionPackModelFile( @@ -246,16 +263,86 @@ describe("pickExtensionPackModelFile", () => { progress, token, ), - ).toEqual(undefined); + ).toEqual(join(tmpDir.path, "my-extension-pack", "models", "my-model.yml")); expect(showQuickPickSpy).toHaveBeenCalledTimes(1); - expect(showQuickPickSpy).toHaveBeenCalledWith( - [], + expect(showInputBoxSpy).toHaveBeenCalledTimes(2); + expect(showInputBoxSpy).toHaveBeenCalledWith( { - title: expect.any(String), + title: expect.stringMatching(/extension pack/i), + prompt: expect.stringMatching(/extension pack/i), + placeHolder: expect.stringMatching(/github\/vscode-codeql-extensions/), + validateInput: expect.any(Function), + }, + token, + ); + 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(tmpDir.path, "my-extension-pack", "codeql-pack.yml"), + "utf8", + ), + ), + ).toEqual({ + name: "my-extension-pack", + version: "0.0.0", + library: true, + extensionTargets: { + "codeql/java-all": "*", + }, + dataExtensions: ["models/**/*.yml"], + }); + }); + + it("allows cancelling the workspace folder selection", async () => { + const cliServer = mockCliServer({}, { models: [], data: {} }); + + showQuickPickSpy.mockResolvedValueOnce(undefined); + + expect( + await pickExtensionPackModelFile( + cliServer, + databaseItem, + progress, + token, + ), + ).toEqual(undefined); + expect(showQuickPickSpy).toHaveBeenCalledTimes(1); + expect(showInputBoxSpy).toHaveBeenCalledTimes(0); + expect(cliServer.resolveQlpacks).toHaveBeenCalled(); + expect(cliServer.resolveExtensions).not.toHaveBeenCalled(); + }); + + it("allows cancelling the extension pack name input", async () => { + const cliServer = mockCliServer({}, { models: [], data: {} }); + + showQuickPickSpy.mockResolvedValueOnce({ + label: "codeql-custom-queries-java", + path: "/a/b/c", + } as QuickPickItem); + showInputBoxSpy.mockResolvedValueOnce(undefined); + + expect( + await pickExtensionPackModelFile( + cliServer, + databaseItem, + progress, + token, + ), + ).toEqual(undefined); + expect(showQuickPickSpy).toHaveBeenCalledTimes(1); + expect(showInputBoxSpy).toHaveBeenCalledTimes(1); + expect(cliServer.resolveQlpacks).toHaveBeenCalled(); expect(cliServer.resolveExtensions).not.toHaveBeenCalled(); }); @@ -592,6 +679,49 @@ describe("pickExtensionPackModelFile", () => { expect(cliServer.resolveExtensions).toHaveBeenCalled(); }); + it("validates the pack name input", async () => { + const cliServer = mockCliServer({}, { models: [], data: {} }); + + showQuickPickSpy.mockResolvedValueOnce({ + label: "a", + path: "/a/b/c", + } as QuickPickItem); + showInputBoxSpy.mockResolvedValue(undefined); + + expect( + await pickExtensionPackModelFile( + cliServer, + databaseItem, + progress, + token, + ), + ).toEqual(undefined); + + const validateFile = showInputBoxSpy.mock.calls[0][0]?.validateInput; + expect(validateFile).toBeDefined(); + if (!validateFile) { + return; + } + + expect(await validateFile("")).toEqual("Pack name must not be empty"); + expect(await validateFile("a".repeat(129))).toEqual( + "Pack name must be no longer than 128 characters", + ); + expect(await validateFile("github/vscode-codeql/extensions")).toEqual( + "Invalid package name: a pack name must contain only lowercase ASCII letters, ASCII digits, and hyphens", + ); + expect(await validateFile("VSCODE")).toEqual( + "Invalid package name: a pack name must contain only lowercase ASCII letters, ASCII digits, and hyphens", + ); + expect(await validateFile("github/vscode-codeql-")).toEqual( + "Invalid package name: a pack name must contain only lowercase ASCII letters, ASCII digits, and hyphens", + ); + expect( + await validateFile("github/vscode-codeql-extensions"), + ).toBeUndefined(); + expect(await validateFile("vscode-codeql-extensions")).toBeUndefined(); + }); + it("validates the file input", async () => { const tmpDir = await dir({ unsafeCleanup: true,