Skip to content

Commit 100b557

Browse files
authored
Merge pull request #2520 from github/koesie10/auto-name-extension-pack
Automatically name extension packs
2 parents 0451dd8 + 7249f4c commit 100b557

File tree

8 files changed

+1011
-66
lines changed

8 files changed

+1011
-66
lines changed

extensions/ql-vscode/src/common/files.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { pathExists, stat, readdir, opendir } from "fs-extra";
22
import { isAbsolute, join, relative, resolve } from "path";
3+
import { tmpdir as osTmpdir } from "os";
34

45
/**
56
* Recursively finds all .ql files in this set of Uris.
@@ -121,3 +122,8 @@ export interface IOError {
121122
export function isIOError(e: any): e is IOError {
122123
return e.code !== undefined && typeof e.code === "string";
123124
}
125+
126+
// This function is a wrapper around `os.tmpdir()` to make it easier to mock in tests.
127+
export function tmpdir(): string {
128+
return osTmpdir();
129+
}

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+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
const packNamePartRegex = /[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
2+
const packNameRegex = new RegExp(
3+
`^(?<scope>${packNamePartRegex.source})/(?<name>${packNamePartRegex.source})$`,
4+
);
5+
const packNameLength = 128;
6+
7+
export interface ExtensionPackName {
8+
scope: string;
9+
name: string;
10+
}
11+
12+
export function formatPackName(packName: ExtensionPackName): string {
13+
return `${packName.scope}/${packName.name}`;
14+
}
15+
16+
export function autoNameExtensionPack(
17+
name: string,
18+
language: string,
19+
): ExtensionPackName | undefined {
20+
let packName = `${name}-${language}`;
21+
if (!packName.includes("/")) {
22+
packName = `pack/${packName}`;
23+
}
24+
25+
const parts = packName.split("/");
26+
const sanitizedParts = parts.map((part) => sanitizeExtensionPackName(part));
27+
28+
// If the scope is empty (e.g. if the given name is "-/b"), then we need to still set a scope
29+
if (sanitizedParts[0].length === 0) {
30+
sanitizedParts[0] = "pack";
31+
}
32+
33+
return {
34+
scope: sanitizedParts[0],
35+
// This will ensure there's only 1 slash
36+
name: sanitizedParts.slice(1).join("-"),
37+
};
38+
}
39+
40+
function sanitizeExtensionPackName(name: string) {
41+
// Lowercase everything
42+
name = name.toLowerCase();
43+
44+
// Replace all spaces, dots, and underscores with hyphens
45+
name = name.replaceAll(/[\s._]+/g, "-");
46+
47+
// Replace all characters which are not allowed by empty strings
48+
name = name.replaceAll(/[^a-z0-9-]/g, "");
49+
50+
// Remove any leading or trailing hyphens
51+
name = name.replaceAll(/^-|-$/g, "");
52+
53+
// Remove any duplicate hyphens
54+
name = name.replaceAll(/-{2,}/g, "-");
55+
56+
return name;
57+
}
58+
59+
export function parsePackName(packName: string): ExtensionPackName | undefined {
60+
const matches = packNameRegex.exec(packName);
61+
if (!matches?.groups) {
62+
return;
63+
}
64+
65+
const scope = matches.groups.scope;
66+
const name = matches.groups.name;
67+
68+
return {
69+
scope,
70+
name,
71+
};
72+
}
73+
74+
export function validatePackName(name: string): string | undefined {
75+
if (!name) {
76+
return "Pack name must not be empty";
77+
}
78+
79+
if (name.length > packNameLength) {
80+
return `Pack name must be no longer than ${packNameLength} characters`;
81+
}
82+
83+
const matches = packNameRegex.exec(name);
84+
if (!matches?.groups) {
85+
if (!name.includes("/")) {
86+
return "Invalid package name: a pack name must contain a slash to separate the scope from the pack name";
87+
}
88+
89+
return "Invalid package name: a pack name must contain only lowercase ASCII letters, ASCII digits, and hyphens";
90+
}
91+
92+
return undefined;
93+
}

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

Lines changed: 142 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,30 @@ import { outputFile, pathExists, readFile } from "fs-extra";
33
import { dump as dumpYaml, load as loadYaml } from "js-yaml";
44
import { minimatch } from "minimatch";
55
import { CancellationToken, window } from "vscode";
6-
import { CodeQLCliServer } from "../codeql-cli/cli";
7-
import {
8-
getOnDiskWorkspaceFolders,
9-
getOnDiskWorkspaceFoldersObjects,
10-
} from "../common/vscode/workspace-folders";
6+
import { CodeQLCliServer, QlpacksInfo } from "../codeql-cli/cli";
7+
import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders";
118
import { ProgressCallback } from "../common/vscode/progress";
129
import { DatabaseItem } from "../databases/local-databases";
1310
import { getQlPackPath, QLPACK_FILENAMES } from "../common/ql";
1411
import { getErrorMessage } from "../common/helpers-pure";
1512
import { ExtensionPack, ExtensionPackModelFile } from "./shared/extension-pack";
1613
import { NotificationLogger, showAndLogErrorMessage } from "../common/logging";
1714
import { containsPath } from "../common/files";
15+
import { disableAutoNameExtensionPack } from "../config";
16+
import {
17+
autoNameExtensionPack,
18+
ExtensionPackName,
19+
formatPackName,
20+
parsePackName,
21+
validatePackName,
22+
} from "./extension-pack-name";
23+
import {
24+
askForWorkspaceFolder,
25+
autoPickExtensionsDirectory,
26+
} from "./extensions-workspace-folder";
1827

1928
const maxStep = 3;
2029

21-
const packNamePartRegex = /[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
22-
const packNameRegex = new RegExp(
23-
`^(?<scope>${packNamePartRegex.source})/(?<name>${packNamePartRegex.source})$`,
24-
);
25-
const packNameLength = 128;
26-
2730
export async function pickExtensionPackModelFile(
2831
cliServer: Pick<CodeQLCliServer, "resolveQlpacks" | "resolveExtensions">,
2932
databaseItem: Pick<DatabaseItem, "name" | "language">,
@@ -79,6 +82,21 @@ async function pickExtensionPack(
7982
true,
8083
);
8184

85+
if (!disableAutoNameExtensionPack()) {
86+
progress({
87+
message: "Creating extension pack...",
88+
step: 2,
89+
maxStep,
90+
});
91+
92+
return autoCreateExtensionPack(
93+
databaseItem.name,
94+
databaseItem.language,
95+
extensionPacksInfo,
96+
logger,
97+
);
98+
}
99+
82100
if (Object.keys(extensionPacksInfo).length === 0) {
83101
return pickNewExtensionPack(databaseItem, token);
84102
}
@@ -239,51 +257,35 @@ async function pickNewExtensionPack(
239257
databaseItem: Pick<DatabaseItem, "name" | "language">,
240258
token: CancellationToken,
241259
): 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-
});
260+
const workspaceFolder = await askForWorkspaceFolder();
254261
if (!workspaceFolder) {
255262
return undefined;
256263
}
257264

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

263-
const packName = await window.showInputBox(
270+
const name = await window.showInputBox(
264271
{
265272
title: "Create new extension pack",
266273
prompt: "Enter name of extension pack",
267-
placeHolder: `e.g. ${examplePackName}`,
274+
placeHolder: examplePackName
275+
? `e.g. ${formatPackName(examplePackName)}`
276+
: "",
268277
validateInput: async (value: string): Promise<string | undefined> => {
269-
if (!value) {
270-
return "Pack name must not be empty";
271-
}
272-
273-
if (value.length > packNameLength) {
274-
return `Pack name must be no longer than ${packNameLength} characters`;
278+
const message = validatePackName(value);
279+
if (message) {
280+
return message;
275281
}
276282

277-
const matches = packNameRegex.exec(value);
278-
if (!matches?.groups) {
279-
if (!value.includes("/")) {
280-
return "Invalid package name: a pack name must contain a slash to separate the scope from the pack name";
281-
}
282-
283-
return "Invalid package name: a pack name must contain only lowercase ASCII letters, ASCII digits, and hyphens";
283+
const packName = parsePackName(value);
284+
if (!packName) {
285+
return "Invalid pack name";
284286
}
285287

286-
const packPath = join(workspaceFolder.path, matches.groups.name);
288+
const packPath = join(workspaceFolder.uri.fsPath, packName.name);
287289
if (await pathExists(packPath)) {
288290
return `A pack already exists at ${packPath}`;
289291
}
@@ -293,31 +295,121 @@ async function pickNewExtensionPack(
293295
},
294296
token,
295297
);
298+
if (!name) {
299+
return undefined;
300+
}
301+
302+
const packName = parsePackName(name);
296303
if (!packName) {
297304
return undefined;
298305
}
299306

300-
const matches = packNameRegex.exec(packName);
301-
if (!matches?.groups) {
302-
return;
307+
const packPath = join(workspaceFolder.uri.fsPath, packName.name);
308+
309+
if (await pathExists(packPath)) {
310+
return undefined;
303311
}
304312

305-
const name = matches.groups.name;
306-
const packPath = join(workspaceFolder.path, name);
313+
return writeExtensionPack(packPath, packName, databaseItem.language);
314+
}
315+
316+
async function autoCreateExtensionPack(
317+
name: string,
318+
language: string,
319+
extensionPacksInfo: QlpacksInfo,
320+
logger: NotificationLogger,
321+
): Promise<ExtensionPack | undefined> {
322+
// Get the extensions directory to create the extension pack in
323+
const extensionsDirectory = await autoPickExtensionsDirectory();
324+
if (!extensionsDirectory) {
325+
return undefined;
326+
}
327+
328+
// Generate the name of the extension pack
329+
const packName = autoNameExtensionPack(name, language);
330+
if (!packName) {
331+
void showAndLogErrorMessage(
332+
logger,
333+
`Could not automatically name extension pack for database ${name}`,
334+
);
335+
336+
return undefined;
337+
}
338+
339+
// Find any existing locations of this extension pack
340+
const existingExtensionPackPaths =
341+
extensionPacksInfo[formatPackName(packName)];
342+
343+
// If there is already an extension pack with this name, use it if it is valid
344+
if (existingExtensionPackPaths?.length === 1) {
345+
let extensionPack: ExtensionPack;
346+
try {
347+
extensionPack = await readExtensionPack(existingExtensionPackPaths[0]);
348+
} catch (e: unknown) {
349+
void showAndLogErrorMessage(
350+
logger,
351+
`Could not read extension pack ${formatPackName(packName)}`,
352+
{
353+
fullMessage: `Could not read extension pack ${formatPackName(
354+
packName,
355+
)} at ${existingExtensionPackPaths[0]}: ${getErrorMessage(e)}`,
356+
},
357+
);
358+
359+
return undefined;
360+
}
361+
362+
return extensionPack;
363+
}
364+
365+
// If there is already an existing extension pack with this name, but it resolves
366+
// to multiple paths, then we can't use it
367+
if (existingExtensionPackPaths?.length > 1) {
368+
void showAndLogErrorMessage(
369+
logger,
370+
`Extension pack ${formatPackName(packName)} resolves to multiple paths`,
371+
{
372+
fullMessage: `Extension pack ${formatPackName(
373+
packName,
374+
)} resolves to multiple paths: ${existingExtensionPackPaths.join(
375+
", ",
376+
)}`,
377+
},
378+
);
379+
380+
return undefined;
381+
}
382+
383+
const packPath = join(extensionsDirectory.fsPath, packName.name);
307384

308385
if (await pathExists(packPath)) {
386+
void showAndLogErrorMessage(
387+
logger,
388+
`Directory ${packPath} already exists for extension pack ${formatPackName(
389+
packName,
390+
)}`,
391+
);
392+
309393
return undefined;
310394
}
311395

396+
return writeExtensionPack(packPath, packName, language);
397+
}
398+
399+
async function writeExtensionPack(
400+
packPath: string,
401+
packName: ExtensionPackName,
402+
language: string,
403+
): Promise<ExtensionPack> {
312404
const packYamlPath = join(packPath, "codeql-pack.yml");
313405

314406
const extensionPack: ExtensionPack = {
315407
path: packPath,
316408
yamlPath: packYamlPath,
317-
name: packName,
409+
name: formatPackName(packName),
318410
version: "0.0.0",
319411
extensionTargets: {
320-
[`codeql/${databaseItem.language}-all`]: "*",
412+
[`codeql/${language}-all`]: "*",
321413
},
322414
dataExtensions: ["models/**/*.yml"],
323415
};

0 commit comments

Comments
 (0)