From ddd00d16b0b9ba264e46d00a2b7d6e4fbb65ffcc Mon Sep 17 00:00:00 2001 From: Elena Tanasoiu Date: Wed, 29 Mar 2023 14:45:50 +0000 Subject: [PATCH] Introduce SkeletonWizard class This will be triggered by a "Create Query" command. It will: - prompt the user for a language - create a skeleton pack based on the language chosen - download a database for the QL pack - open the new query file If the skeleton pack already exists, we just create a new query file in the existing folder. If the database is already downloaded, we just re-use it. --- extensions/ql-vscode/src/skeleton-query.ts | 218 +++++++++++++++ .../cli-integration/skeleton-query.test.ts | 255 ++++++++++++++++++ 2 files changed, 473 insertions(+) create mode 100644 extensions/ql-vscode/src/skeleton-query.ts create mode 100644 extensions/ql-vscode/test/vscode-tests/cli-integration/skeleton-query.test.ts diff --git a/extensions/ql-vscode/src/skeleton-query.ts b/extensions/ql-vscode/src/skeleton-query.ts new file mode 100644 index 000000000..11b13be3b --- /dev/null +++ b/extensions/ql-vscode/src/skeleton-query.ts @@ -0,0 +1,218 @@ +import { join } from "path"; +import { CancellationToken, Uri, workspace } from "vscode"; +import { CodeQLCliServer } from "./cli"; +import { OutputChannelLogger } from "./common"; +import { Credentials } from "./common/authentication"; +import { QueryLanguage } from "./common/query-language"; +import { askForLanguage, isFolderAlreadyInWorkspace } from "./helpers"; +import { getErrorMessage } from "./pure/helpers-pure"; +import { QlPackGenerator } from "./qlpack-generator"; +import { DatabaseItem, DatabaseManager } from "./local-databases"; +import * as databaseFetcher from "./databaseFetcher"; +import { ProgressCallback } from "./progress"; + +type QueryLanguagesToDatabaseMap = { + [id: string]: string; +}; + +export class SkeletonQueryWizard { + private language: string | undefined; + private folderName: string | undefined; + private fileName = "example.ql"; + + QUERY_LANGUAGE_TO_DATABASE_REPO: QueryLanguagesToDatabaseMap = { + csharp: "github/codeql", + python: "github/codeql", + ruby: "github/codeql", + javascript: "github/codeql", + go: "github/codeql", + ql: "github/codeql", + }; + + constructor( + private readonly cliServer: CodeQLCliServer, + private readonly storagePath: string | undefined, + private readonly progress: ProgressCallback, + private readonly credentials: Credentials | undefined, + private readonly extLogger: OutputChannelLogger, + private readonly databaseManager: DatabaseManager, + private readonly token: CancellationToken, + ) {} + + public async execute() { + // show quick pick to choose language + await this.chooseLanguage(); + if (!this.language) { + return; + } + + this.folderName = `codeql-custom-queries-${this.language}`; + const skeletonPackAlreadyExists = isFolderAlreadyInWorkspace( + this.folderName, + ); + + if (skeletonPackAlreadyExists) { + // just create a new example query file in skeleton QL pack + await this.createExampleFile(); + // select existing database for language + await this.selectExistingDatabase(); + } else { + // generate a new skeleton QL pack with query file + await this.createQlPack(); + // download database based on language and select it + await this.downloadDatabase(); + } + + // open a query file + await this.openExampleFile(); + } + + private async openExampleFile() { + if (this.folderName === undefined || this.storagePath === undefined) { + throw new Error("Path to folder is undefined"); + } + + const queryFileUri = Uri.file( + join(this.storagePath, this.folderName, this.fileName), + ); + + void workspace.openTextDocument(queryFileUri).then((doc) => { + void Window.showTextDocument(doc); + }); + } + + private async chooseLanguage() { + this.progress({ + message: "Choose language", + step: 1, + maxStep: 1, + }); + + this.language = await askForLanguage(this.cliServer, false); + } + + private async createQlPack() { + if (this.folderName === undefined) { + throw new Error("Folder name is undefined"); + } + + this.progress({ + message: "Creating skeleton QL pack around query", + step: 2, + maxStep: 2, + }); + + try { + const qlPackGenerator = new QlPackGenerator( + this.folderName, + this.language as QueryLanguage, + this.cliServer, + this.storagePath, + ); + + await qlPackGenerator.generate(); + } catch (e: unknown) { + void this.extLogger.log( + `Could not create skeleton QL pack: ${getErrorMessage(e)}`, + ); + } + } + + private async createExampleFile() { + if (this.folderName === undefined) { + throw new Error("Folder name is undefined"); + } + + this.progress({ + message: + "Skeleton query pack already exists. Creating additional query example file.", + step: 2, + maxStep: 2, + }); + + try { + const qlPackGenerator = new QlPackGenerator( + this.folderName, + this.language as QueryLanguage, + this.cliServer, + this.storagePath, + ); + + this.fileName = await this.workoutNextFileName(this.folderName); + await qlPackGenerator.createExampleQlFile(this.fileName); + } catch (e: unknown) { + void this.extLogger.log( + `Could not create skeleton QL pack: ${getErrorMessage(e)}`, + ); + } + } + + private async workoutNextFileName(folderName: string): Promise { + if (this.storagePath === undefined) { + throw new Error("Workspace storage path is undefined"); + } + + const folderUri = Uri.file(join(this.storagePath, folderName)); + const files = await workspace.fs.readDirectory(folderUri); + const qlFiles = files.filter((file) => + file[0].endsWith(".ql") ? true : false, + ); + + return `example${qlFiles.length + 1}.ql`; + } + + private async downloadDatabase() { + if (this.storagePath === undefined) { + throw new Error("Workspace storage path is undefined"); + } + + if (this.language === undefined) { + throw new Error("Workspace storage path is undefined"); + } + + this.progress({ + message: "Downloading database", + step: 3, + maxStep: 3, + }); + + const githubRepoNwo = this.QUERY_LANGUAGE_TO_DATABASE_REPO[this.language]; + + await databaseFetcher.downloadGitHubDatabase( + githubRepoNwo, + this.databaseManager, + this.storagePath, + this.credentials, + this.progress, + this.token, + this.cliServer, + ); + } + + private async selectExistingDatabase() { + if (this.language === undefined) { + throw new Error("Language is undefined"); + } + + if (this.storagePath === undefined) { + throw new Error("Workspace storage path is undefined"); + } + + const databaseNwo = this.QUERY_LANGUAGE_TO_DATABASE_REPO[this.language]; + + const databaseItem = await this.databaseManager.digForDatabaseItem( + this.language, + databaseNwo, + ); + + if (databaseItem) { + // select the found database + await this.databaseManager.setCurrentDatabaseItem( + databaseItem as DatabaseItem, + ); + } else { + // download new database and select it + await this.downloadDatabase(); + } + } +} diff --git a/extensions/ql-vscode/test/vscode-tests/cli-integration/skeleton-query.test.ts b/extensions/ql-vscode/test/vscode-tests/cli-integration/skeleton-query.test.ts new file mode 100644 index 000000000..9a83cb9a8 --- /dev/null +++ b/extensions/ql-vscode/test/vscode-tests/cli-integration/skeleton-query.test.ts @@ -0,0 +1,255 @@ +import { CodeQLCliServer } from "../../../src/cli"; +import { SkeletonQueryWizard } from "../../../src/skeleton-query"; +import { mockedObject, mockedQuickPickItem } from "../utils/mocking.helpers"; +import * as tmp from "tmp"; +import { TextDocument, window, workspace } from "vscode"; +import { extLogger } from "../../../src/common"; +import { QlPackGenerator } from "../../../src/qlpack-generator"; +import * as helpers from "../../../src/helpers"; +import { ensureDirSync, removeSync } from "fs-extra"; +import { join } from "path"; +import { CancellationTokenSource } from "vscode-jsonrpc"; +import { testCredentialsWithStub } from "../../factories/authentication"; +import { DatabaseItem, DatabaseManager } from "../../../src/local-databases"; +import * as databaseFetcher from "../../../src/databaseFetcher"; +import { Credentials } from "../../../src/common/authentication"; +import { getErrorMessage } from "../../../src/pure/helpers-pure"; + +describe("SkeletonQueryWizard", () => { + let mockCli: CodeQLCliServer; + let mockDatabaseManager: DatabaseManager; + let wizard: SkeletonQueryWizard; + let dir: tmp.DirResult; + let storagePath: string; + let credentials: Credentials; + let quickPickSpy: jest.SpiedFunction; + let generateSpy: jest.SpiedFunction< + typeof QlPackGenerator.prototype.generate + >; + let createExampleQlFileSpy: jest.SpiedFunction< + typeof QlPackGenerator.prototype.createExampleQlFile + >; + let chosenLanguage: string; + let downloadGitHubDatabaseSpy: jest.SpiedFunction< + typeof databaseFetcher.downloadGitHubDatabase + >; + let openTextDocumentSpy: jest.SpiedFunction< + typeof workspace.openTextDocument + >; + + beforeEach(async () => { + dir = tmp.dirSync({ + prefix: "skeleton_query_wizard_", + unsafeCleanup: true, + }); + chosenLanguage = "ruby"; + + mockCli = mockedObject({ + resolveLanguages: jest + .fn() + .mockResolvedValue([ + "ruby", + "javascript", + "go", + "java", + "python", + "csharp", + "cpp", + ]), + getSupportedLanguages: jest.fn(), + }); + + mockDatabaseManager = mockedObject({ + setCurrentDatabaseItem: jest.fn(), + digForDatabaseItem: jest.fn(), + }); + + storagePath = dir.name; + credentials = testCredentialsWithStub(); + + jest.spyOn(extLogger, "log").mockResolvedValue(undefined); + jest.spyOn(workspace, "workspaceFolders", "get").mockReturnValue([ + { + name: `codespaces-codeql`, + uri: { path: storagePath }, + }, + { + name: "/second/folder/path", + uri: { path: storagePath }, + }, + ] as WorkspaceFolder[]); + + quickPickSpy = jest + .spyOn(window, "showQuickPick") + .mockResolvedValueOnce(mockedQuickPickItem(chosenLanguage)); + generateSpy = jest + .spyOn(QlPackGenerator.prototype, "generate") + .mockResolvedValue(undefined); + createExampleQlFileSpy = jest + .spyOn(QlPackGenerator.prototype, "createExampleQlFile") + .mockResolvedValue(undefined); + downloadGitHubDatabaseSpy = jest + .spyOn(databaseFetcher, "downloadGitHubDatabase") + .mockResolvedValue(undefined); + openTextDocumentSpy = jest + .spyOn(workspace, "openTextDocument") + .mockResolvedValue({} as TextDocument); + + const token = new CancellationTokenSource().token; + + wizard = new SkeletonQueryWizard( + mockCli, + storagePath, + jest.fn(), + credentials, + extLogger, + mockDatabaseManager, + token, + ); + }); + + afterEach(async () => { + dir.removeCallback(); + }); + + it("should prompt for language", async () => { + await wizard.execute(); + + expect(mockCli.getSupportedLanguages).toHaveBeenCalled(); + expect(quickPickSpy).toHaveBeenCalled(); + }); + + describe("if QL pack doesn't exist", () => { + beforeEach(() => { + jest.spyOn(helpers, "isFolderAlreadyInWorkspace").mockReturnValue(false); + }); + it("should try to create a new QL pack based on the language", async () => { + await wizard.execute(); + + expect(generateSpy).toHaveBeenCalled(); + }); + + it("should download database for selected language", async () => { + await wizard.execute(); + + expect(downloadGitHubDatabaseSpy).toHaveBeenCalled(); + }); + + it("should open the query file", async () => { + await wizard.execute(); + + expect(openTextDocumentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringMatching("example.ql"), + }), + ); + }); + }); + + describe("if QL pack exists", () => { + beforeEach(async () => { + jest.spyOn(helpers, "isFolderAlreadyInWorkspace").mockReturnValue(true); + + // create a skeleton codeql-custom-queries-${language} folder + // with an example QL file inside + ensureDirSync( + join(dir.name, `codeql-custom-queries-${chosenLanguage}`, "example.ql"), + ); + }); + + it("should create new query file in the same QL pack folder", async () => { + await wizard.execute(); + + expect(createExampleQlFileSpy).toHaveBeenCalledWith("example2.ql"); + }); + + describe("if QL pack has no query file", () => { + it("should create new query file in the same QL pack folder", async () => { + removeSync( + join( + dir.name, + `codeql-custom-queries-${chosenLanguage}`, + "example.ql", + ), + ); + await wizard.execute(); + + expect(createExampleQlFileSpy).toHaveBeenCalledWith("example1.ql"); + }); + + it("should open the query file", async () => { + removeSync( + join( + dir.name, + `codeql-custom-queries-${chosenLanguage}`, + "example.ql", + ), + ); + + await wizard.execute(); + + expect(openTextDocumentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringMatching("example1.ql"), + }), + ); + }); + }); + + describe("if database is also already downloaded", () => { + let databaseNwo: string; + let databaseItem: DatabaseItem; + + beforeEach(async () => { + databaseNwo = wizard.QUERY_LANGUAGE_TO_DATABASE_REPO[chosenLanguage]; + + databaseItem = { + name: databaseNwo, + language: chosenLanguage, + } as DatabaseItem; + + jest + .spyOn(mockDatabaseManager, "digForDatabaseItem") + .mockResolvedValue([databaseItem] as any); + }); + + it("should not download a new database for language", async () => { + await wizard.execute(); + + expect(downloadGitHubDatabaseSpy).not.toHaveBeenCalled(); + }); + + it("should select an existing database", async () => { + await wizard.execute(); + + expect(mockDatabaseManager.setCurrentDatabaseItem).toHaveBeenCalledWith( + [databaseItem], + ); + }); + + it("should open the new query file", async () => { + await wizard.execute(); + + expect(openTextDocumentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringMatching("example2.ql"), + }), + ); + }); + }); + + describe("if database is missing", () => { + beforeEach(async () => { + jest + .spyOn(mockDatabaseManager, "digForDatabaseItem") + .mockResolvedValue(undefined); + }); + + it("should download a new database for language", async () => { + await wizard.execute(); + + expect(downloadGitHubDatabaseSpy).toHaveBeenCalled(); + }); + }); + }); +});