@@ -2,8 +2,8 @@ import { join, relative, resolve, sep } from "path";
22import { outputFile , pathExists , readFile } from "fs-extra" ;
33import { dump as dumpYaml , load as loadYaml } from "js-yaml" ;
44import { 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" ;
77import {
88 getOnDiskWorkspaceFolders ,
99 getOnDiskWorkspaceFoldersObjects ,
@@ -15,6 +15,7 @@ import { getErrorMessage } from "../pure/helpers-pure";
1515import { ExtensionPack , ExtensionPackModelFile } from "./shared/extension-pack" ;
1616import { NotificationLogger , showAndLogErrorMessage } from "../common/logging" ;
1717import { containsPath } from "../pure/files" ;
18+ import { disableAutoNameExtensionPack } from "../config" ;
1819
1920const 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 - z 0 - 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