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.
This commit is contained in:
218
extensions/ql-vscode/src/skeleton-query.ts
Normal file
218
extensions/ql-vscode/src/skeleton-query.ts
Normal file
@@ -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<string> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof window.showQuickPick>;
|
||||||
|
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<CodeQLCliServer>({
|
||||||
|
resolveLanguages: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
"ruby",
|
||||||
|
"javascript",
|
||||||
|
"go",
|
||||||
|
"java",
|
||||||
|
"python",
|
||||||
|
"csharp",
|
||||||
|
"cpp",
|
||||||
|
]),
|
||||||
|
getSupportedLanguages: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockDatabaseManager = mockedObject<DatabaseManager>({
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user