Skip to content

Commit 549884d

Browse files
committed
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.
1 parent 0f9d127 commit 549884d

3 files changed

Lines changed: 421 additions & 33 deletions

File tree

extensions/ql-vscode/src/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,7 +714,15 @@ export function showQueriesPanel(): boolean {
714714

715715
const DATA_EXTENSIONS = new Setting("dataExtensions", ROOT_SETTING);
716716
const LLM_GENERATION = new Setting("llmGeneration", DATA_EXTENSIONS);
717+
const DISABLE_AUTO_NAME_EXTENSION_PACK = new Setting(
718+
"disableAutoNameExtensionPack",
719+
DATA_EXTENSIONS,
720+
);
717721

718722
export function showLlmGeneration(): boolean {
719723
return !!LLM_GENERATION.getValue<boolean>();
720724
}
725+
726+
export function disableAutoNameExtensionPack(): boolean {
727+
return !!DISABLE_AUTO_NAME_EXTENSION_PACK.getValue<boolean>();
728+
}

extensions/ql-vscode/src/data-extensions-editor/extension-pack-picker.ts

Lines changed: 196 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { join, relative, resolve, sep } from "path";
22
import { outputFile, pathExists, readFile } from "fs-extra";
33
import { dump as dumpYaml, load as loadYaml } from "js-yaml";
44
import { minimatch } from "minimatch";
5-
import { CancellationToken, window } from "vscode";
6-
import { CodeQLCliServer } from "../codeql-cli/cli";
5+
import { CancellationToken, window, WorkspaceFolder } from "vscode";
6+
import { CodeQLCliServer, QlpacksInfo } from "../codeql-cli/cli";
77
import {
88
getOnDiskWorkspaceFolders,
99
getOnDiskWorkspaceFoldersObjects,
@@ -15,6 +15,7 @@ import { getErrorMessage } from "../pure/helpers-pure";
1515
import { ExtensionPack, ExtensionPackModelFile } from "./shared/extension-pack";
1616
import { NotificationLogger, showAndLogErrorMessage } from "../common/logging";
1717
import { containsPath } from "../pure/files";
18+
import { disableAutoNameExtensionPack } from "../config";
1819

1920
const maxStep = 3;
2021

@@ -79,6 +80,21 @@ async function pickExtensionPack(
7980
true,
8081
);
8182

83+
if (!disableAutoNameExtensionPack()) {
84+
progress({
85+
message: "Creating extension pack...",
86+
step: 2,
87+
maxStep,
88+
});
89+
90+
return autoCreateExtensionPack(
91+
databaseItem.name,
92+
databaseItem.language,
93+
extensionPacksInfo,
94+
logger,
95+
);
96+
}
97+
8298
if (Object.keys(extensionPacksInfo).length === 0) {
8399
return pickNewExtensionPack(databaseItem, token);
84100
}
@@ -239,26 +255,15 @@ async function pickNewExtensionPack(
239255
databaseItem: Pick<DatabaseItem, "name" | "language">,
240256
token: CancellationToken,
241257
): Promise<ExtensionPack | undefined> {
242-
const workspaceFolders = getOnDiskWorkspaceFoldersObjects();
243-
const workspaceFolderOptions = workspaceFolders.map((folder) => ({
244-
label: folder.name,
245-
detail: folder.uri.fsPath,
246-
path: folder.uri.fsPath,
247-
}));
248-
249-
// We're not using window.showWorkspaceFolderPick because that also includes the database source folders while
250-
// we only want to include on-disk workspace folders.
251-
const workspaceFolder = await window.showQuickPick(workspaceFolderOptions, {
252-
title: "Select workspace folder to create extension pack in",
253-
});
258+
const workspaceFolder = await askForWorkspaceFolder();
254259
if (!workspaceFolder) {
255260
return undefined;
256261
}
257262

258-
let examplePackName = `${databaseItem.name}-extensions`;
259-
if (!examplePackName.includes("/")) {
260-
examplePackName = `pack/${examplePackName}`;
261-
}
263+
const examplePackName = autoNameExtensionPack(
264+
databaseItem.name,
265+
databaseItem.language,
266+
);
262267

263268
const packName = await window.showInputBox(
264269
{
@@ -283,7 +288,7 @@ async function pickNewExtensionPack(
283288
return "Invalid package name: a pack name must contain only lowercase ASCII letters, ASCII digits, and hyphens";
284289
}
285290

286-
const packPath = join(workspaceFolder.path, matches.groups.name);
291+
const packPath = join(workspaceFolder.uri.fsPath, matches.groups.name);
287292
if (await pathExists(packPath)) {
288293
return `A pack already exists at ${packPath}`;
289294
}
@@ -303,12 +308,145 @@ async function pickNewExtensionPack(
303308
}
304309

305310
const name = matches.groups.name;
306-
const packPath = join(workspaceFolder.path, name);
311+
const packPath = join(workspaceFolder.uri.fsPath, name);
307312

308313
if (await pathExists(packPath)) {
309314
return undefined;
310315
}
311316

317+
return writeExtensionPack(packPath, packName, databaseItem.language);
318+
}
319+
320+
async function autoCreateExtensionPack(
321+
name: string,
322+
language: string,
323+
extensionPacksInfo: QlpacksInfo,
324+
logger: NotificationLogger,
325+
): Promise<ExtensionPack | undefined> {
326+
const workspaceFolder = await autoPickWorkspaceFolder(language);
327+
if (!workspaceFolder) {
328+
return undefined;
329+
}
330+
331+
const packName = autoNameExtensionPack(name, language);
332+
if (!packName) {
333+
void showAndLogErrorMessage(
334+
logger,
335+
`Could not automatically name extension pack for database ${name}`,
336+
);
337+
338+
return undefined;
339+
}
340+
341+
const existingExtensionPackPaths = extensionPacksInfo[packName];
342+
if (existingExtensionPackPaths?.length === 1) {
343+
let extensionPack: ExtensionPack;
344+
try {
345+
extensionPack = await readExtensionPack(existingExtensionPackPaths[0]);
346+
} catch (e: unknown) {
347+
void showAndLogErrorMessage(
348+
logger,
349+
`Could not read extension pack ${packName}`,
350+
{
351+
fullMessage: `Could not read extension pack ${packName} at ${
352+
existingExtensionPackPaths[0]
353+
}: ${getErrorMessage(e)}`,
354+
},
355+
);
356+
357+
return undefined;
358+
}
359+
360+
return extensionPack;
361+
} else if (existingExtensionPackPaths?.length > 1) {
362+
void showAndLogErrorMessage(
363+
logger,
364+
`Extension pack ${packName} resolves to multiple paths`,
365+
{
366+
fullMessage: `Extension pack ${packName} resolves to multiple paths: ${existingExtensionPackPaths.join(
367+
", ",
368+
)}`,
369+
},
370+
);
371+
372+
return undefined;
373+
}
374+
375+
const matches = packNameRegex.exec(packName);
376+
if (!matches?.groups) {
377+
void showAndLogErrorMessage(
378+
logger,
379+
`Extension pack ${packName} does not have a valid name`,
380+
);
381+
382+
return undefined;
383+
}
384+
385+
const unscopedName = matches.groups.name;
386+
const packPath = join(workspaceFolder.uri.fsPath, unscopedName);
387+
388+
if (await pathExists(packPath)) {
389+
void showAndLogErrorMessage(
390+
logger,
391+
`Directory ${packPath} already exists for extension pack ${packName}`,
392+
);
393+
394+
return undefined;
395+
}
396+
397+
return writeExtensionPack(packPath, packName, language);
398+
}
399+
400+
async function autoPickWorkspaceFolder(
401+
language: string,
402+
): Promise<WorkspaceFolder | undefined> {
403+
const workspaceFolders = getOnDiskWorkspaceFoldersObjects();
404+
405+
if (workspaceFolders.length === 1) {
406+
return workspaceFolders[0];
407+
}
408+
const starterWorkspaceFolderForLanguage = workspaceFolders.find(
409+
(folder) => folder.name === `codeql-custom-queries-${language}`,
410+
);
411+
if (starterWorkspaceFolderForLanguage) {
412+
return starterWorkspaceFolderForLanguage;
413+
}
414+
415+
const workspaceFolderForLanguage = workspaceFolders.find((folder) =>
416+
folder.name.endsWith(`-${language}`),
417+
);
418+
if (workspaceFolderForLanguage) {
419+
return workspaceFolderForLanguage;
420+
}
421+
422+
return askForWorkspaceFolder();
423+
}
424+
425+
async function askForWorkspaceFolder(): Promise<WorkspaceFolder | undefined> {
426+
const workspaceFolders = getOnDiskWorkspaceFoldersObjects();
427+
const workspaceFolderOptions = workspaceFolders.map((folder) => ({
428+
label: folder.name,
429+
detail: folder.uri.fsPath,
430+
folder,
431+
}));
432+
433+
// We're not using window.showWorkspaceFolderPick because that also includes the database source folders while
434+
// we only want to include on-disk workspace folders.
435+
const workspaceFolder = await window.showQuickPick(workspaceFolderOptions, {
436+
title: "Select workspace folder to create extension pack in",
437+
});
438+
if (!workspaceFolder) {
439+
return undefined;
440+
}
441+
442+
return workspaceFolder.folder;
443+
}
444+
445+
async function writeExtensionPack(
446+
packPath: string,
447+
packName: string,
448+
language: string,
449+
): Promise<ExtensionPack> {
312450
const packYamlPath = join(packPath, "codeql-pack.yml");
313451

314452
const extensionPack: ExtensionPack = {
@@ -317,7 +455,7 @@ async function pickNewExtensionPack(
317455
name: packName,
318456
version: "0.0.0",
319457
extensionTargets: {
320-
[`codeql/${databaseItem.language}-all`]: "*",
458+
[`codeql/${language}-all`]: "*",
321459
},
322460
dataExtensions: ["models/**/*.yml"],
323461
};
@@ -420,3 +558,40 @@ async function readExtensionPack(path: string): Promise<ExtensionPack> {
420558
dataExtensions,
421559
};
422560
}
561+
562+
function autoNameExtensionPack(
563+
name: string,
564+
language: string,
565+
): string | undefined {
566+
let packName = `${name}-${language}`;
567+
if (!packName.includes("/")) {
568+
packName = `pack/${packName}`;
569+
}
570+
571+
const parts = packName.split("/");
572+
const sanitizedParts = parts.map((part) => sanitizeExtensionPackName(part));
573+
574+
// This will ensure there's only 1 slash
575+
packName = `${sanitizedParts[0]}/${sanitizedParts.slice(1).join("-")}`;
576+
577+
return packName;
578+
}
579+
580+
function sanitizeExtensionPackName(name: string) {
581+
// Lowercase everything
582+
name = name.toLowerCase();
583+
584+
// Replace all spaces, dots, and underscores with hyphens
585+
name = name.replaceAll(/[\s._]+/g, "-");
586+
587+
// Replace all characters which are not allowed by empty strings
588+
name = name.replaceAll(/[^a-z0-9-]/g, "");
589+
590+
// Remove any leading or trailing hyphens
591+
name = name.replaceAll(/^-|-$/g, "");
592+
593+
// Remove any duplicate hyphens
594+
name = name.replaceAll(/-{2,}/g, "-");
595+
596+
return name;
597+
}

0 commit comments

Comments
 (0)