Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions extensions/ql-vscode/src/common/files.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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();
}
8 changes: 8 additions & 0 deletions extensions/ql-vscode/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>();
}

export function disableAutoNameExtensionPack(): boolean {
return !!DISABLE_AUTO_NAME_EXTENSION_PACK.getValue<boolean>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
const packNamePartRegex = /[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
const packNameRegex = new RegExp(
`^(?<scope>${packNamePartRegex.source})/(?<name>${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));

// 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
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,30 @@ 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 {
getOnDiskWorkspaceFolders,
getOnDiskWorkspaceFoldersObjects,
} from "../common/vscode/workspace-folders";
import { CodeQLCliServer, QlpacksInfo } from "../codeql-cli/cli";
import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders";
import { ProgressCallback } from "../common/vscode/progress";
import { DatabaseItem } from "../databases/local-databases";
import { getQlPackPath, QLPACK_FILENAMES } from "../common/ql";
import { getErrorMessage } from "../common/helpers-pure";
import { ExtensionPack, ExtensionPackModelFile } from "./shared/extension-pack";
import { NotificationLogger, showAndLogErrorMessage } from "../common/logging";
import { containsPath } from "../common/files";
import { disableAutoNameExtensionPack } from "../config";
import {
autoNameExtensionPack,
ExtensionPackName,
formatPackName,
parsePackName,
validatePackName,
} from "./extension-pack-name";
import {
askForWorkspaceFolder,
autoPickExtensionsDirectory,
} from "./extensions-workspace-folder";

const maxStep = 3;

const packNamePartRegex = /[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
const packNameRegex = new RegExp(
`^(?<scope>${packNamePartRegex.source})/(?<name>${packNamePartRegex.source})$`,
);
const packNameLength = 128;

export async function pickExtensionPackModelFile(
cliServer: Pick<CodeQLCliServer, "resolveQlpacks" | "resolveExtensions">,
databaseItem: Pick<DatabaseItem, "name" | "language">,
Expand Down Expand Up @@ -79,6 +82,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);
}
Expand Down Expand Up @@ -239,51 +257,35 @@ async function pickNewExtensionPack(
databaseItem: Pick<DatabaseItem, "name" | "language">,
token: CancellationToken,
): Promise<ExtensionPack | undefined> {
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(
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<string | undefined> => {
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.path, matches.groups.name);
const packPath = join(workspaceFolder.uri.fsPath, packName.name);
if (await pathExists(packPath)) {
return `A pack already exists at ${packPath}`;
}
Expand All @@ -293,31 +295,121 @@ async function pickNewExtensionPack(
},
token,
);
if (!name) {
return undefined;
}

const packName = parsePackName(name);
if (!packName) {
return undefined;
}

const matches = packNameRegex.exec(packName);
if (!matches?.groups) {
return;
const packPath = join(workspaceFolder.uri.fsPath, packName.name);

if (await pathExists(packPath)) {
return undefined;
}

const name = matches.groups.name;
const packPath = join(workspaceFolder.path, name);
return writeExtensionPack(packPath, packName, databaseItem.language);
}

async function autoCreateExtensionPack(
name: string,
language: string,
extensionPacksInfo: QlpacksInfo,
logger: NotificationLogger,
): Promise<ExtensionPack | undefined> {
// Get the extensions directory to create the extension pack in
const extensionsDirectory = await autoPickExtensionsDirectory();
if (!extensionsDirectory) {
return undefined;
}

// Generate the name of the extension pack
const packName = autoNameExtensionPack(name, language);
if (!packName) {
void showAndLogErrorMessage(
logger,
`Could not automatically name extension pack for database ${name}`,
);

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;
try {
extensionPack = await readExtensionPack(existingExtensionPackPaths[0]);
} catch (e: unknown) {
void showAndLogErrorMessage(
logger,
`Could not read extension pack ${formatPackName(packName)}`,
{
fullMessage: `Could not read extension pack ${formatPackName(
packName,
)} at ${existingExtensionPackPaths[0]}: ${getErrorMessage(e)}`,
},
);

return undefined;
}

return extensionPack;
}

// 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 ${formatPackName(packName)} resolves to multiple paths`,
{
fullMessage: `Extension pack ${formatPackName(
packName,
)} resolves to multiple paths: ${existingExtensionPackPaths.join(
", ",
)}`,
},
);

return undefined;
}

const packPath = join(extensionsDirectory.fsPath, packName.name);

if (await pathExists(packPath)) {
void showAndLogErrorMessage(
logger,
`Directory ${packPath} already exists for extension pack ${formatPackName(
packName,
)}`,
);

return undefined;
}

return writeExtensionPack(packPath, packName, language);
}

async function writeExtensionPack(
packPath: string,
packName: ExtensionPackName,
language: string,
): Promise<ExtensionPack> {
const packYamlPath = join(packPath, "codeql-pack.yml");

const extensionPack: ExtensionPack = {
path: packPath,
yamlPath: packYamlPath,
name: packName,
name: formatPackName(packName),
version: "0.0.0",
extensionTargets: {
[`codeql/${databaseItem.language}-all`]: "*",
[`codeql/${language}-all`]: "*",
},
dataExtensions: ["models/**/*.yml"],
};
Expand Down
Loading