diff --git a/CODEOWNERS b/CODEOWNERS index 206ac3cb0..63f6b3914 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,5 +1,6 @@ **/* @github/codeql-vscode-reviewers **/variant-analysis/ @github/code-scanning-secexp-reviewers **/databases/ @github/code-scanning-secexp-reviewers +**/method-modeling/ @github/code-scanning-secexp-reviewers **/model-editor/ @github/code-scanning-secexp-reviewers **/queries-panel/ @github/code-scanning-secexp-reviewers diff --git a/extensions/ql-vscode/CHANGELOG.md b/extensions/ql-vscode/CHANGELOG.md index b14968c30..56c1e2a9c 100644 --- a/extensions/ql-vscode/CHANGELOG.md +++ b/extensions/ql-vscode/CHANGELOG.md @@ -6,6 +6,8 @@ - It is now possible to show the language of query history items using the `%l` specifier in the `codeQL.queryHistory.format` setting. Note that this only works for queries run after this upgrade, and older items will show `unknown` as a language. [#2892](https://github.com/github/vscode-codeql/pull/2892) - Increase the required version of VS Code to 1.82.0. [#2877](https://github.com/github/vscode-codeql/pull/2877) - Fix a bug where the query server was restarted twice after configuration changes. [#2884](https://github.com/github/vscode-codeql/pull/2884). +- Add support for the `telemetry.telemetryLevel` setting. For more information, see the [telemetry documentation](https://codeql.github.com/docs/codeql-for-visual-studio-code/about-telemetry-in-codeql-for-visual-studio-code). [#2824](https://github.com/github/vscode-codeql/pull/2824). +- Fix syntax highlighting directly after import statements with instantiation arguments. [#2792](https://github.com/github/vscode-codeql/pull/2792) ## 1.9.1 - 29 September 2023 diff --git a/extensions/ql-vscode/package.json b/extensions/ql-vscode/package.json index 18e3c3746..0e7ea511a 100644 --- a/extensions/ql-vscode/package.json +++ b/extensions/ql-vscode/package.json @@ -450,13 +450,20 @@ "type": "boolean", "default": false, "scope": "application", - "markdownDescription": "Specifies whether to send CodeQL usage telemetry. This setting AND the global `#telemetry.enableTelemetry#` setting must be checked for telemetry to be sent to GitHub. For more information, see the [telemetry documentation](https://codeql.github.com/docs/codeql-for-visual-studio-code/about-telemetry-in-codeql-for-visual-studio-code)" + "markdownDescription": "Specifies whether to send CodeQL usage telemetry. This setting AND the one of the global telemetry settings (`#telemetry.enableTelemetry#` or `#telemetry.telemetryLevel#`) must be enabled for telemetry to be sent to GitHub. For more information, see the [telemetry documentation](https://codeql.github.com/docs/codeql-for-visual-studio-code/about-telemetry-in-codeql-for-visual-studio-code)", + "tags": [ + "telemetry", + "usesOnlineServices" + ] }, "codeQL.telemetry.logTelemetry": { "type": "boolean", "default": false, "scope": "application", - "description": "Specifies whether or not to write telemetry events to the extension log." + "description": "Specifies whether or not to write telemetry events to the extension log.", + "tags": [ + "telemetry" + ] } } } diff --git a/extensions/ql-vscode/src/common/interface-types.ts b/extensions/ql-vscode/src/common/interface-types.ts index 650d48498..58c96d3b2 100644 --- a/extensions/ql-vscode/src/common/interface-types.ts +++ b/extensions/ql-vscode/src/common/interface-types.ts @@ -546,8 +546,7 @@ interface RefreshMethods { interface SaveModeledMethods { t: "saveModeledMethods"; - methods: Method[]; - modeledMethods: Record; + methodSignatures?: string[]; } interface GenerateMethodMessage { @@ -587,7 +586,7 @@ interface SetInModelingModeMessage { interface RevealMethodMessage { t: "revealMethod"; - method: Method; + methodSignature: string; } export type ToModelEditorMessage = diff --git a/extensions/ql-vscode/src/common/vscode/telemetry.ts b/extensions/ql-vscode/src/common/vscode/telemetry.ts index eb4a0f407..a2470b4cd 100644 --- a/extensions/ql-vscode/src/common/vscode/telemetry.ts +++ b/extensions/ql-vscode/src/common/vscode/telemetry.ts @@ -3,13 +3,13 @@ import { Extension, ExtensionContext, ConfigurationChangeEvent, + env, } from "vscode"; import TelemetryReporter from "vscode-extension-telemetry"; import { ConfigListener, CANARY_FEATURES, ENABLE_TELEMETRY, - GLOBAL_ENABLE_TELEMETRY, LOG_TELEMETRY, isIntegrationTestMode, isCanary, @@ -59,8 +59,6 @@ export class ExtensionTelemetryListener extends ConfigListener implements AppTelemetry { - static relevantSettings = [ENABLE_TELEMETRY, CANARY_FEATURES]; - private reporter?: TelemetryReporter; private cliVersionStr = NOT_SET_CLI_VERSION; @@ -72,6 +70,10 @@ export class ExtensionTelemetryListener private readonly ctx: ExtensionContext, ) { super(); + + env.onDidChangeTelemetryEnabled(async () => { + await this.initialize(); + }); } /** @@ -91,10 +93,7 @@ export class ExtensionTelemetryListener async handleDidChangeConfiguration( e: ConfigurationChangeEvent, ): Promise { - if ( - e.affectsConfiguration("codeQL.telemetry.enableTelemetry") || - e.affectsConfiguration("telemetry.enableTelemetry") - ) { + if (e.affectsConfiguration(ENABLE_TELEMETRY.qualifiedName)) { await this.initialize(); } @@ -102,7 +101,7 @@ export class ExtensionTelemetryListener // Re-request if codeQL.canary is being set to `true` and telemetry // is not currently enabled. if ( - e.affectsConfiguration("codeQL.canary") && + e.affectsConfiguration(CANARY_FEATURES.qualifiedName) && CANARY_FEATURES.getValue() && !ENABLE_TELEMETRY.getValue() ) { @@ -212,7 +211,7 @@ export class ExtensionTelemetryListener properties.stack = error.stack; } - this.reporter.sendTelemetryEvent("error", properties, {}); + this.reporter.sendTelemetryErrorEvent("error", properties, {}); } /** @@ -224,7 +223,7 @@ export class ExtensionTelemetryListener // if global telemetry is disabled, avoid showing the dialog or making any changes let result = undefined; if ( - GLOBAL_ENABLE_TELEMETRY.getValue() && + env.isTelemetryEnabled && // Avoid showing the dialog if we are in integration test mode. !isIntegrationTestMode() ) { diff --git a/extensions/ql-vscode/src/config.ts b/extensions/ql-vscode/src/config.ts index 94ddebdf3..172cddcee 100644 --- a/extensions/ql-vscode/src/config.ts +++ b/extensions/ql-vscode/src/config.ts @@ -72,15 +72,8 @@ export const VSCODE_SAVE_BEFORE_START_SETTING = new Setting( const ROOT_SETTING = new Setting("codeQL"); -// Global configuration +// Telemetry configuration const TELEMETRY_SETTING = new Setting("telemetry", ROOT_SETTING); -const AST_VIEWER_SETTING = new Setting("astViewer", ROOT_SETTING); -const CONTEXTUAL_QUERIES_SETTINGS = new Setting( - "contextualQueries", - ROOT_SETTING, -); -const GLOBAL_TELEMETRY_SETTING = new Setting("telemetry"); -const LOG_INSIGHTS_SETTING = new Setting("logInsights", ROOT_SETTING); export const LOG_TELEMETRY = new Setting("logTelemetry", TELEMETRY_SETTING); export const ENABLE_TELEMETRY = new Setting( @@ -88,11 +81,6 @@ export const ENABLE_TELEMETRY = new Setting( TELEMETRY_SETTING, ); -export const GLOBAL_ENABLE_TELEMETRY = new Setting( - "enableTelemetry", - GLOBAL_TELEMETRY_SETTING, -); - // Distribution configuration const DISTRIBUTION_SETTING = new Setting("cli", ROOT_SETTING); export const CUSTOM_CODEQL_PATH_SETTING = new Setting( @@ -475,6 +463,7 @@ export function allowCanaryQueryServer() { return value === undefined ? true : !!value; } +const LOG_INSIGHTS_SETTING = new Setting("logInsights", ROOT_SETTING); export const JOIN_ORDER_WARNING_THRESHOLD = new Setting( "joinOrderWarningThreshold", LOG_INSIGHTS_SETTING, @@ -484,6 +473,7 @@ export function joinOrderWarningThreshold(): number { return JOIN_ORDER_WARNING_THRESHOLD.getValue(); } +const AST_VIEWER_SETTING = new Setting("astViewer", ROOT_SETTING); /** * Hidden setting: Avoids caching in the AST viewer if the user is also a canary user. */ @@ -492,6 +482,10 @@ export const NO_CACHE_AST_VIEWER = new Setting( AST_VIEWER_SETTING, ); +const CONTEXTUAL_QUERIES_SETTINGS = new Setting( + "contextualQueries", + ROOT_SETTING, +); /** * Hidden setting: Avoids caching in jump to def and find refs contextual queries if the user is also a canary user. */ @@ -711,20 +705,33 @@ const LLM_GENERATION = new Setting("llmGeneration", MODEL_SETTING); const EXTENSIONS_DIRECTORY = new Setting("extensionsDirectory", MODEL_SETTING); const SHOW_MULTIPLE_MODELS = new Setting("showMultipleModels", MODEL_SETTING); -export function showFlowGeneration(): boolean { - return !!FLOW_GENERATION.getValue(); +export interface ModelConfig { + flowGeneration: boolean; + llmGeneration: boolean; + getExtensionsDirectory(languageId: string): string | undefined; + showMultipleModels: boolean; } -export function showLlmGeneration(): boolean { - return !!LLM_GENERATION.getValue(); -} +export class ModelConfigListener extends ConfigListener implements ModelConfig { + protected handleDidChangeConfiguration(e: ConfigurationChangeEvent): void { + this.handleDidChangeConfigurationForRelevantSettings([MODEL_SETTING], e); + } -export function getExtensionsDirectory(languageId: string): string | undefined { - return EXTENSIONS_DIRECTORY.getValue({ - languageId, - }); -} + public get flowGeneration(): boolean { + return !!FLOW_GENERATION.getValue(); + } -export function showMultipleModels(): boolean { - return !!SHOW_MULTIPLE_MODELS.getValue(); + public get llmGeneration(): boolean { + return !!LLM_GENERATION.getValue(); + } + + public getExtensionsDirectory(languageId: string): string | undefined { + return EXTENSIONS_DIRECTORY.getValue({ + languageId, + }); + } + + public get showMultipleModels(): boolean { + return !!SHOW_MULTIPLE_MODELS.getValue(); + } } diff --git a/extensions/ql-vscode/src/model-editor/extension-pack-picker.ts b/extensions/ql-vscode/src/model-editor/extension-pack-picker.ts index a02251723..c970002d0 100644 --- a/extensions/ql-vscode/src/model-editor/extension-pack-picker.ts +++ b/extensions/ql-vscode/src/model-editor/extension-pack-picker.ts @@ -11,7 +11,7 @@ import { getQlPackPath, QLPACK_FILENAMES } from "../common/ql"; import { getErrorMessage } from "../common/helpers-pure"; import { ExtensionPack } from "./shared/extension-pack"; import { NotificationLogger, showAndLogErrorMessage } from "../common/logging"; -import { getExtensionsDirectory } from "../config"; +import { ModelConfig } from "../config"; import { autoNameExtensionPack, ExtensionPackName, @@ -28,6 +28,7 @@ const extensionPackValidate = ajv.compile(extensionPackMetadataSchemaJson); export async function pickExtensionPack( cliServer: Pick, databaseItem: Pick, + modelConfig: ModelConfig, logger: NotificationLogger, progress: ProgressCallback, maxStep: number, @@ -56,7 +57,9 @@ export async function pickExtensionPack( }); // Get the `codeQL.model.extensionsDirectory` setting for the language - const userExtensionsDirectory = getExtensionsDirectory(databaseItem.language); + const userExtensionsDirectory = modelConfig.getExtensionsDirectory( + databaseItem.language, + ); // If the setting is not set, automatically pick a suitable directory const extensionsDirectory = userExtensionsDirectory diff --git a/extensions/ql-vscode/src/model-editor/method-modeling/method-modeling-panel.ts b/extensions/ql-vscode/src/model-editor/method-modeling/method-modeling-panel.ts index f120bcd19..e4e54694b 100644 --- a/extensions/ql-vscode/src/model-editor/method-modeling/method-modeling-panel.ts +++ b/extensions/ql-vscode/src/model-editor/method-modeling/method-modeling-panel.ts @@ -5,6 +5,7 @@ import { MethodModelingViewProvider } from "./method-modeling-view-provider"; import { Method } from "../method"; import { ModelingStore } from "../modeling-store"; import { ModelEditorViewTracker } from "../model-editor-view-tracker"; +import { ModelConfigListener } from "../../config"; export class MethodModelingPanel extends DisposableObject { private readonly provider: MethodModelingViewProvider; @@ -16,10 +17,16 @@ export class MethodModelingPanel extends DisposableObject { ) { super(); + // This is here instead of in MethodModelingViewProvider because we need to + // dispose this when the extension gets disposed, not when the webview gets + // disposed. + const modelConfig = this.push(new ModelConfigListener()); + this.provider = new MethodModelingViewProvider( app, modelingStore, editorViewTracker, + modelConfig, ); this.push( window.registerWebviewViewProvider( diff --git a/extensions/ql-vscode/src/model-editor/method-modeling/method-modeling-view-provider.ts b/extensions/ql-vscode/src/model-editor/method-modeling/method-modeling-view-provider.ts index e80c5528a..207807836 100644 --- a/extensions/ql-vscode/src/model-editor/method-modeling/method-modeling-view-provider.ts +++ b/extensions/ql-vscode/src/model-editor/method-modeling/method-modeling-view-provider.ts @@ -12,7 +12,7 @@ import { DbModelingState, ModelingStore } from "../modeling-store"; import { AbstractWebviewViewProvider } from "../../common/vscode/abstract-webview-view-provider"; import { assertNever } from "../../common/helpers-pure"; import { ModelEditorViewTracker } from "../model-editor-view-tracker"; -import { showMultipleModels } from "../../config"; +import { ModelConfigListener } from "../../config"; export class MethodModelingViewProvider extends AbstractWebviewViewProvider< ToMethodModelingMessage, @@ -26,6 +26,7 @@ export class MethodModelingViewProvider extends AbstractWebviewViewProvider< app: App, private readonly modelingStore: ModelingStore, private readonly editorViewTracker: ModelEditorViewTracker, + private readonly modelConfig: ModelConfigListener, ) { super(app, "method-modeling"); } @@ -33,13 +34,14 @@ export class MethodModelingViewProvider extends AbstractWebviewViewProvider< protected override async onWebViewLoaded(): Promise { await Promise.all([this.setViewState(), this.setInitialState()]); this.registerToModelingStoreEvents(); + this.registerToModelConfigEvents(); } private async setViewState(): Promise { await this.postMessage({ t: "setMethodModelingPanelViewState", viewState: { - showMultipleModels: showMultipleModels(), + showMultipleModels: this.modelConfig.showMultipleModels, }, }); } @@ -198,4 +200,12 @@ export class MethodModelingViewProvider extends AbstractWebviewViewProvider< }), ); } + + private registerToModelConfigEvents(): void { + this.push( + this.modelConfig.onDidChangeConfiguration(() => { + void this.setViewState(); + }), + ); + } } diff --git a/extensions/ql-vscode/src/model-editor/methods-usage/methods-usage-data-provider.ts b/extensions/ql-vscode/src/model-editor/methods-usage/methods-usage-data-provider.ts index fe8ee875d..444276579 100644 --- a/extensions/ql-vscode/src/model-editor/methods-usage/methods-usage-data-provider.ts +++ b/extensions/ql-vscode/src/model-editor/methods-usage/methods-usage-data-provider.ts @@ -102,7 +102,10 @@ export class MethodsUsageDataProvider const modeledMethod = this.modeledMethods[method.signature]; const modifiedMethod = this.modifiedMethodSignatures.has(method.signature); - const status = getModelingStatus(modeledMethod, modifiedMethod); + const status = getModelingStatus( + modeledMethod ? [modeledMethod] : [], + modifiedMethod, + ); switch (status) { case "unmodeled": return new ThemeIcon("error", new ThemeColor("errorForeground")); diff --git a/extensions/ql-vscode/src/model-editor/model-editor-module.ts b/extensions/ql-vscode/src/model-editor/model-editor-module.ts index c5645de2c..1eb7c0f17 100644 --- a/extensions/ql-vscode/src/model-editor/model-editor-module.ts +++ b/extensions/ql-vscode/src/model-editor/model-editor-module.ts @@ -21,6 +21,7 @@ import { MethodModelingPanel } from "./method-modeling/method-modeling-panel"; import { ModelingStore } from "./modeling-store"; import { showResolvableLocation } from "../databases/local-databases/locations"; import { ModelEditorViewTracker } from "./model-editor-view-tracker"; +import { ModelConfigListener } from "../config"; const SUPPORTED_LANGUAGES: string[] = ["java", "csharp"]; @@ -150,9 +151,12 @@ export class ModelEditorModule extends DisposableObject { return; } + const modelConfig = this.push(new ModelConfigListener()); + const modelFile = await pickExtensionPack( this.cliServer, db, + modelConfig, this.app.logger, progress, maxStep, @@ -172,7 +176,12 @@ export class ModelEditorModule extends DisposableObject { unsafeCleanup: true, }); - const success = await setUpPack(this.cliServer, queryDir, language); + const success = await setUpPack( + this.cliServer, + queryDir, + language, + modelConfig, + ); if (!success) { await cleanupQueryDir(); return; @@ -188,6 +197,7 @@ export class ModelEditorModule extends DisposableObject { this.app, this.modelingStore, this.editorViewTracker, + modelConfig, this.databaseManager, this.cliServer, this.queryRunner, diff --git a/extensions/ql-vscode/src/model-editor/model-editor-queries.ts b/extensions/ql-vscode/src/model-editor/model-editor-queries.ts index 063452d03..7fc0e7562 100644 --- a/extensions/ql-vscode/src/model-editor/model-editor-queries.ts +++ b/extensions/ql-vscode/src/model-editor/model-editor-queries.ts @@ -4,7 +4,7 @@ import { writeFile } from "fs-extra"; import { dump } from "js-yaml"; import { prepareExternalApiQuery } from "./external-api-usage-queries"; import { CodeQLCliServer } from "../codeql-cli/cli"; -import { showLlmGeneration } from "../config"; +import { ModelConfig } from "../config"; import { Mode } from "./shared/mode"; import { resolveQueriesFromPacks } from "../local-queries"; import { modeTag } from "./mode-tag"; @@ -28,12 +28,14 @@ export const syntheticQueryPackName = "codeql/external-api-usage"; * @param cliServer The CodeQL CLI server to use. * @param queryDir The directory to set up. * @param language The language to use for the queries. + * @param modelConfig The model config to use. * @returns true if the setup was successful, false otherwise. */ export async function setUpPack( cliServer: CodeQLCliServer, queryDir: string, language: QueryLanguage, + modelConfig: ModelConfig, ): Promise { // Download the required query packs await cliServer.packDownload([`codeql/${language}-queries`]); @@ -84,7 +86,7 @@ export async function setUpPack( } // Download any other required packs - if (language === "java" && showLlmGeneration()) { + if (language === "java" && modelConfig.llmGeneration) { await cliServer.packDownload([`codeql/${language}-automodel-queries`]); } diff --git a/extensions/ql-vscode/src/model-editor/model-editor-view.ts b/extensions/ql-vscode/src/model-editor/model-editor-view.ts index a2a0e42ba..a768168e6 100644 --- a/extensions/ql-vscode/src/model-editor/model-editor-view.ts +++ b/extensions/ql-vscode/src/model-editor/model-editor-view.ts @@ -17,8 +17,8 @@ import { import { ProgressCallback, withProgress } from "../common/vscode/progress"; import { QueryRunner } from "../query-server"; import { - showAndLogExceptionWithTelemetry, showAndLogErrorMessage, + showAndLogExceptionWithTelemetry, } from "../common/logging"; import { DatabaseItem, DatabaseManager } from "../databases/local-databases"; import { CodeQLCliServer } from "../codeql-cli/cli"; @@ -34,11 +34,7 @@ import { import { Method, Usage } from "./method"; import { ModeledMethod } from "./modeled-method"; import { ExtensionPack } from "./shared/extension-pack"; -import { - showFlowGeneration, - showLlmGeneration, - showMultipleModels, -} from "../config"; +import { ModelConfigListener } from "../config"; import { Mode } from "./shared/mode"; import { loadModeledMethods, saveModeledMethods } from "./modeled-method-fs"; import { pickExtensionPack } from "./extension-pack-picker"; @@ -47,6 +43,10 @@ import { AutoModeler } from "./auto-modeler"; import { telemetryListener } from "../common/vscode/telemetry"; import { ModelingStore } from "./modeling-store"; import { ModelEditorViewTracker } from "./model-editor-view-tracker"; +import { + convertFromLegacyModeledMethods, + convertToLegacyModeledMethods, +} from "./modeled-methods-legacy"; export class ModelEditorView extends AbstractWebview< ToModelEditorMessage, @@ -58,6 +58,7 @@ export class ModelEditorView extends AbstractWebview< protected readonly app: App, private readonly modelingStore: ModelingStore, private readonly viewTracker: ModelEditorViewTracker, + private readonly modelConfig: ModelConfigListener, private readonly databaseManager: DatabaseManager, private readonly cliServer: CodeQLCliServer, private readonly queryRunner: QueryRunner, @@ -71,6 +72,7 @@ export class ModelEditorView extends AbstractWebview< this.modelingStore.initializeStateForDb(databaseItem); this.registerToModelingStoreEvents(); + this.registerToModelConfigEvents(); this.viewTracker.registerView(this); @@ -201,47 +203,58 @@ export class ModelEditorView extends AbstractWebview< break; case "saveModeledMethods": - await withProgress( - async (progress) => { - progress({ - step: 1, - maxStep: 500 + externalApiQueriesProgressMaxStep, - message: "Writing model files", - }); - await saveModeledMethods( - this.extensionPack, - this.databaseItem.language, - msg.methods, - msg.modeledMethods, - this.mode, - this.cliServer, - this.app.logger, - ); + { + const methods = this.modelingStore.getMethods( + this.databaseItem, + msg.methodSignatures, + ); + const modeledMethods = this.modelingStore.getModeledMethods( + this.databaseItem, + msg.methodSignatures, + ); - await Promise.all([ - this.setViewState(), - this.loadMethods((update) => - progress({ - ...update, - step: update.step + 500, - maxStep: 500 + externalApiQueriesProgressMaxStep, - }), - ), - ]); - }, - { - cancellable: false, - }, - ); + await withProgress( + async (progress) => { + progress({ + step: 1, + maxStep: 500 + externalApiQueriesProgressMaxStep, + message: "Writing model files", + }); + await saveModeledMethods( + this.extensionPack, + this.databaseItem.language, + methods, + convertFromLegacyModeledMethods(modeledMethods), + this.mode, + this.cliServer, + this.app.logger, + ); - this.modelingStore.removeModifiedMethods( - this.databaseItem, - Object.keys(msg.modeledMethods), - ); + await Promise.all([ + this.setViewState(), + this.loadMethods((update) => + progress({ + ...update, + step: update.step + 500, + maxStep: 500 + externalApiQueriesProgressMaxStep, + }), + ), + ]); + }, + { + cancellable: false, + }, + ); - void telemetryListener?.sendUIInteraction( - "model-editor-save-modeled-methods", - ); + this.modelingStore.removeModifiedMethods( + this.databaseItem, + Object.keys(modeledMethods), + ); + + void telemetryListener?.sendUIInteraction( + "model-editor-save-modeled-methods", + ); + } break; case "generateMethod": @@ -328,21 +341,21 @@ export class ModelEditorView extends AbstractWebview< await this.postMessage({ t: "revealMethod", - method, + methodSignature: method.signature, }); } private async setViewState(): Promise { const showLlmButton = - this.databaseItem.language === "java" && showLlmGeneration(); + this.databaseItem.language === "java" && this.modelConfig.llmGeneration; await this.postMessage({ t: "setModelEditorViewState", viewState: { extensionPack: this.extensionPack, - showFlowGeneration: showFlowGeneration(), + showFlowGeneration: this.modelConfig.flowGeneration, showLlmButton, - showMultipleModels: showMultipleModels(), + showMultipleModels: this.modelConfig.showMultipleModels, mode: this.mode, }, }); @@ -359,7 +372,10 @@ export class ModelEditorView extends AbstractWebview< this.cliServer, this.app.logger, ); - this.modelingStore.setModeledMethods(this.databaseItem, modeledMethods); + this.modelingStore.setModeledMethods( + this.databaseItem, + convertToLegacyModeledMethods(modeledMethods), + ); } catch (e: unknown) { void showAndLogErrorMessage( this.app.logger, @@ -481,6 +497,7 @@ export class ModelEditorView extends AbstractWebview< const modelFile = await pickExtensionPack( this.cliServer, addedDatabase, + this.modelConfig, this.app.logger, progress, 3, @@ -493,6 +510,7 @@ export class ModelEditorView extends AbstractWebview< this.app, this.modelingStore, this.viewTracker, + this.modelConfig, this.databaseManager, this.cliServer, this.queryRunner, @@ -614,6 +632,14 @@ export class ModelEditorView extends AbstractWebview< ); } + private registerToModelConfigEvents() { + this.push( + this.modelConfig.onDidChangeConfiguration(() => { + void this.setViewState(); + }), + ); + } + private addModeledMethods(modeledMethods: Record) { this.modelingStore.addModeledMethods(this.databaseItem, modeledMethods); diff --git a/extensions/ql-vscode/src/model-editor/modeled-method-fs.ts b/extensions/ql-vscode/src/model-editor/modeled-method-fs.ts index 8f3ce34aa..5e8ad9347 100644 --- a/extensions/ql-vscode/src/model-editor/modeled-method-fs.ts +++ b/extensions/ql-vscode/src/model-editor/modeled-method-fs.ts @@ -10,17 +10,12 @@ import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; import { load as loadYaml } from "js-yaml"; import { CodeQLCliServer } from "../codeql-cli/cli"; import { pathsEqual } from "../common/files"; -import { - convertFromLegacyModeledMethods, - convertFromLegacyModeledMethodsFiles, - convertToLegacyModeledMethods, -} from "./modeled-methods-legacy"; export async function saveModeledMethods( extensionPack: ExtensionPack, language: string, methods: Method[], - modeledMethods: Record, + modeledMethods: Record, mode: Mode, cliServer: CodeQLCliServer, logger: NotificationLogger, @@ -34,8 +29,8 @@ export async function saveModeledMethods( const yamls = createDataExtensionYamls( language, methods, - convertFromLegacyModeledMethods(modeledMethods), - convertFromLegacyModeledMethodsFiles(existingModeledMethods), + modeledMethods, + existingModeledMethods, mode, ); @@ -50,12 +45,12 @@ async function loadModeledMethodFiles( extensionPack: ExtensionPack, cliServer: CodeQLCliServer, logger: NotificationLogger, -): Promise>> { +): Promise>> { const modelFiles = await listModelFiles(extensionPack.path, cliServer); const modeledMethodsByFile: Record< string, - Record + Record > = {}; for (const modelFile of modelFiles) { @@ -73,8 +68,7 @@ async function loadModeledMethodFiles( ); continue; } - modeledMethodsByFile[modelFile] = - convertToLegacyModeledMethods(modeledMethods); + modeledMethodsByFile[modelFile] = modeledMethods; } return modeledMethodsByFile; @@ -84,8 +78,8 @@ export async function loadModeledMethods( extensionPack: ExtensionPack, cliServer: CodeQLCliServer, logger: NotificationLogger, -): Promise> { - const existingModeledMethods: Record = {}; +): Promise> { + const existingModeledMethods: Record = {}; const modeledMethodsByFile = await loadModeledMethodFiles( extensionPack, @@ -94,7 +88,11 @@ export async function loadModeledMethods( ); for (const modeledMethods of Object.values(modeledMethodsByFile)) { for (const [key, value] of Object.entries(modeledMethods)) { - existingModeledMethods[key] = value; + if (!(key in existingModeledMethods)) { + existingModeledMethods[key] = []; + } + + existingModeledMethods[key].push(...value); } } diff --git a/extensions/ql-vscode/src/model-editor/modeled-methods-legacy.ts b/extensions/ql-vscode/src/model-editor/modeled-methods-legacy.ts index a482af0e3..2e6f3af1c 100644 --- a/extensions/ql-vscode/src/model-editor/modeled-methods-legacy.ts +++ b/extensions/ql-vscode/src/model-editor/modeled-methods-legacy.ts @@ -21,13 +21,3 @@ export function convertToLegacyModeledMethods( }), ); } - -export function convertFromLegacyModeledMethodsFiles( - modeledMethods: Record>, -): Record> { - return Object.fromEntries( - Object.entries(modeledMethods).map(([filename, modeledMethods]) => { - return [filename, convertFromLegacyModeledMethods(modeledMethods)]; - }), - ); -} diff --git a/extensions/ql-vscode/src/model-editor/modeling-store.ts b/extensions/ql-vscode/src/model-editor/modeling-store.ts index c6e27986d..faa959a09 100644 --- a/extensions/ql-vscode/src/model-editor/modeling-store.ts +++ b/extensions/ql-vscode/src/model-editor/modeling-store.ts @@ -169,6 +169,23 @@ export class ModelingStore extends DisposableObject { return this.state.size > 0; } + /** + * Returns the methods for the given database item and method signatures. + * If the `methodSignatures` argument is not provided or is undefined, returns all methods. + */ + public getMethods( + dbItem: DatabaseItem, + methodSignatures?: string[], + ): Method[] { + const methods = this.getState(dbItem).methods; + if (!methodSignatures) { + return methods; + } + return methods.filter((method) => + methodSignatures.includes(method.signature), + ); + } + public setMethods(dbItem: DatabaseItem, methods: Method[]) { const dbState = this.getState(dbItem); const dbUri = dbItem.databaseUri.toString(); @@ -197,6 +214,25 @@ export class ModelingStore extends DisposableObject { }); } + /** + * Returns the modeled methods for the given database item and method signatures. + * If the `methodSignatures` argument is not provided or is undefined, returns all modeled methods. + */ + public getModeledMethods( + dbItem: DatabaseItem, + methodSignatures?: string[], + ): Record { + const modeledMethods = this.getState(dbItem).modeledMethods; + if (!methodSignatures) { + return modeledMethods; + } + return Object.fromEntries( + Object.entries(modeledMethods).filter(([key]) => + methodSignatures.includes(key), + ), + ); + } + public addModeledMethods( dbItem: DatabaseItem, methods: Record, diff --git a/extensions/ql-vscode/src/model-editor/shared/modeling-status.ts b/extensions/ql-vscode/src/model-editor/shared/modeling-status.ts index 496b4c0e7..f0737ec32 100644 --- a/extensions/ql-vscode/src/model-editor/shared/modeling-status.ts +++ b/extensions/ql-vscode/src/model-editor/shared/modeling-status.ts @@ -3,13 +3,13 @@ import { ModeledMethod } from "../modeled-method"; export type ModelingStatus = "unmodeled" | "unsaved" | "saved"; export function getModelingStatus( - modeledMethod: ModeledMethod | undefined, + modeledMethods: ModeledMethod[], methodIsUnsaved: boolean, ): ModelingStatus { - if (modeledMethod) { + if (modeledMethods.length > 0) { if (methodIsUnsaved) { return "unsaved"; - } else if (modeledMethod.type !== "none") { + } else if (modeledMethods.some((m) => m.type !== "none")) { return "saved"; } } diff --git a/extensions/ql-vscode/src/stories/method-modeling/MethodModeling.stories.tsx b/extensions/ql-vscode/src/stories/method-modeling/MethodModeling.stories.tsx index 46d05c3d4..e611378ee 100644 --- a/extensions/ql-vscode/src/stories/method-modeling/MethodModeling.stories.tsx +++ b/extensions/ql-vscode/src/stories/method-modeling/MethodModeling.stories.tsx @@ -4,6 +4,7 @@ import { Meta, StoryFn } from "@storybook/react"; import { MethodModeling as MethodModelingComponent } from "../../view/method-modeling/MethodModeling"; import { createMethod } from "../../../test/factories/model-editor/method-factories"; +import { createModeledMethod } from "../../../test/factories/model-editor/modeled-method-factories"; export default { title: "Method Modeling/Method Modeling", component: MethodModelingComponent, @@ -18,18 +19,53 @@ const method = createMethod(); export const MethodUnmodeled = Template.bind({}); MethodUnmodeled.args = { method, + modeledMethods: [], modelingStatus: "unmodeled", }; export const MethodModeled = Template.bind({}); MethodModeled.args = { method, - + modeledMethods: [], modelingStatus: "unsaved", }; export const MethodSaved = Template.bind({}); MethodSaved.args = { method, + modeledMethods: [], + modelingStatus: "saved", +}; + +export const MultipleModelingsUnmodeled = Template.bind({}); +MultipleModelingsUnmodeled.args = { + method, + modeledMethods: [], + showMultipleModels: true, + modelingStatus: "saved", +}; + +export const MultipleModelingsModeledSingle = Template.bind({}); +MultipleModelingsModeledSingle.args = { + method, + modeledMethods: [createModeledMethod(method)], + showMultipleModels: true, + modelingStatus: "saved", +}; + +export const MultipleModelingsModeledMultiple = Template.bind({}); +MultipleModelingsModeledMultiple.args = { + method, + modeledMethods: [ + createModeledMethod(method), + createModeledMethod({ + ...method, + type: "source", + input: "", + output: "ReturnValue", + kind: "remote", + }), + ], + showMultipleModels: true, modelingStatus: "saved", }; diff --git a/extensions/ql-vscode/src/stories/model-editor/MethodRow.stories.tsx b/extensions/ql-vscode/src/stories/model-editor/MethodRow.stories.tsx index 9c2118914..9ea43028a 100644 --- a/extensions/ql-vscode/src/stories/model-editor/MethodRow.stories.tsx +++ b/extensions/ql-vscode/src/stories/model-editor/MethodRow.stories.tsx @@ -7,6 +7,9 @@ import { CallClassification, Method } from "../../model-editor/method"; import { ModeledMethod } from "../../model-editor/modeled-method"; import { VSCodeDataGrid } from "@vscode/webview-ui-toolkit/react"; import { GRID_TEMPLATE_COLUMNS } from "../../view/model-editor/ModeledMethodDataGrid"; +import { ModelEditorViewState } from "../../model-editor/shared/view-state"; +import { createMockExtensionPack } from "../../../test/factories/model-editor/extension-pack"; +import { Mode } from "../../model-editor/shared/mode"; export default { title: "CodeQL Model Editor/Method Row", @@ -66,51 +69,78 @@ const modeledMethod: ModeledMethod = { methodParameters: "()", }; +const viewState: ModelEditorViewState = { + extensionPack: createMockExtensionPack(), + showFlowGeneration: true, + showLlmButton: true, + showMultipleModels: true, + mode: Mode.Application, +}; + export const Unmodeled = Template.bind({}); Unmodeled.args = { method, - modeledMethod: undefined, + modeledMethods: [], methodCanBeModeled: true, + viewState, }; export const Source = Template.bind({}); Source.args = { method, - modeledMethod: { ...modeledMethod, type: "source" }, + modeledMethods: [{ ...modeledMethod, type: "source" }], methodCanBeModeled: true, + viewState, }; export const Sink = Template.bind({}); Sink.args = { method, - modeledMethod: { ...modeledMethod, type: "sink" }, + modeledMethods: [{ ...modeledMethod, type: "sink" }], methodCanBeModeled: true, + viewState, }; export const Summary = Template.bind({}); Summary.args = { method, - modeledMethod: { ...modeledMethod, type: "summary" }, + modeledMethods: [{ ...modeledMethod, type: "summary" }], methodCanBeModeled: true, + viewState, }; export const Neutral = Template.bind({}); Neutral.args = { method, - modeledMethod: { ...modeledMethod, type: "neutral" }, + modeledMethods: [{ ...modeledMethod, type: "neutral" }], methodCanBeModeled: true, + viewState, }; export const AlreadyModeled = Template.bind({}); AlreadyModeled.args = { method: { ...method, supported: true }, - modeledMethod: undefined, + modeledMethods: [], + viewState, }; export const ModelingInProgress = Template.bind({}); ModelingInProgress.args = { method, - modeledMethod, + modeledMethods: [modeledMethod], modelingInProgress: true, methodCanBeModeled: true, + viewState, +}; + +export const MultipleModelings = Template.bind({}); +MultipleModelings.args = { + method, + modeledMethods: [ + { ...modeledMethod, type: "source" }, + { ...modeledMethod, type: "sink" }, + { ...modeledMethod }, + ], + methodCanBeModeled: true, + viewState, }; diff --git a/extensions/ql-vscode/src/view/common/icon/Codicon.tsx b/extensions/ql-vscode/src/view/common/icon/Codicon.tsx index c4b5714e1..c1eb3a6fd 100644 --- a/extensions/ql-vscode/src/view/common/icon/Codicon.tsx +++ b/extensions/ql-vscode/src/view/common/icon/Codicon.tsx @@ -4,7 +4,7 @@ import classNames from "classnames"; type Props = { name: string; - label: string; + label?: string; className?: string; slot?: string; }; diff --git a/extensions/ql-vscode/src/view/method-modeling/MethodModeling.tsx b/extensions/ql-vscode/src/view/method-modeling/MethodModeling.tsx index 4e8a993bc..bb46da9dc 100644 --- a/extensions/ql-vscode/src/view/method-modeling/MethodModeling.tsx +++ b/extensions/ql-vscode/src/view/method-modeling/MethodModeling.tsx @@ -5,9 +5,9 @@ import { ModelingStatusIndicator } from "../model-editor/ModelingStatusIndicator import { Method } from "../../model-editor/method"; import { MethodName } from "../model-editor/MethodName"; import { ModeledMethod } from "../../model-editor/modeled-method"; -import { MethodModelingInputs } from "./MethodModelingInputs"; import { VSCodeTag } from "@vscode/webview-ui-toolkit/react"; import { ReviewInEditorButton } from "./ReviewInEditorButton"; +import { ModeledMethodsPanel } from "./ModeledMethodsPanel"; const Container = styled.div` padding-top: 0.5rem; @@ -38,10 +38,6 @@ const DependencyContainer = styled.div` margin-bottom: 0.8rem; `; -const StyledMethodModelingInputs = styled(MethodModelingInputs)` - padding-bottom: 0.5rem; -`; - const StyledVSCodeTag = styled(VSCodeTag)<{ visible: boolean }>` visibility: ${(props) => (props.visible ? "visible" : "hidden")}; `; @@ -55,15 +51,16 @@ const UnsavedTag = ({ modelingStatus }: { modelingStatus: ModelingStatus }) => ( export type MethodModelingProps = { modelingStatus: ModelingStatus; method: Method; - modeledMethod: ModeledMethod | undefined; + modeledMethods: ModeledMethod[]; showMultipleModels?: boolean; onChange: (modeledMethod: ModeledMethod) => void; }; export const MethodModeling = ({ modelingStatus, - modeledMethod, + modeledMethods, method, + showMultipleModels = false, onChange, }: MethodModelingProps): JSX.Element => { return ( @@ -77,9 +74,10 @@ export const MethodModeling = ({ - diff --git a/extensions/ql-vscode/src/view/method-modeling/MethodModelingView.tsx b/extensions/ql-vscode/src/view/method-modeling/MethodModelingView.tsx index 2c32c3ee5..ea9c9a9b2 100644 --- a/extensions/ql-vscode/src/view/method-modeling/MethodModelingView.tsx +++ b/extensions/ql-vscode/src/view/method-modeling/MethodModelingView.tsx @@ -30,7 +30,8 @@ export function MethodModelingView({ initialViewState }: Props): JSX.Element { const [isMethodModified, setIsMethodModified] = useState(false); const modelingStatus = useMemo( - () => getModelingStatus(modeledMethod, isMethodModified), + () => + getModelingStatus(modeledMethod ? [modeledMethod] : [], isMethodModified), [modeledMethod, isMethodModified], ); @@ -94,7 +95,7 @@ export function MethodModelingView({ initialViewState }: Props): JSX.Element { diff --git a/extensions/ql-vscode/src/view/method-modeling/ModeledMethodsPanel.tsx b/extensions/ql-vscode/src/view/method-modeling/ModeledMethodsPanel.tsx new file mode 100644 index 000000000..824a10efd --- /dev/null +++ b/extensions/ql-vscode/src/view/method-modeling/ModeledMethodsPanel.tsx @@ -0,0 +1,44 @@ +import * as React from "react"; +import { ModeledMethod } from "../../model-editor/modeled-method"; +import { MethodModelingInputs } from "./MethodModelingInputs"; +import { Method } from "../../model-editor/method"; +import { styled } from "styled-components"; +import { MultipleModeledMethodsPanel } from "./MultipleModeledMethodsPanel"; + +export type ModeledMethodsPanelProps = { + method: Method; + modeledMethods: ModeledMethod[]; + showMultipleModels: boolean; + onChange: (modeledMethod: ModeledMethod) => void; +}; + +const SingleMethodModelingInputs = styled(MethodModelingInputs)` + padding-bottom: 0.5rem; +`; + +export const ModeledMethodsPanel = ({ + method, + modeledMethods, + showMultipleModels, + onChange, +}: ModeledMethodsPanelProps) => { + if (!showMultipleModels) { + return ( + 0 ? modeledMethods[0] : undefined + } + onChange={onChange} + /> + ); + } + + return ( + + ); +}; diff --git a/extensions/ql-vscode/src/view/method-modeling/MultipleModeledMethodsPanel.tsx b/extensions/ql-vscode/src/view/method-modeling/MultipleModeledMethodsPanel.tsx new file mode 100644 index 000000000..9d2fb2fc7 --- /dev/null +++ b/extensions/ql-vscode/src/view/method-modeling/MultipleModeledMethodsPanel.tsx @@ -0,0 +1,95 @@ +import * as React from "react"; +import { useCallback, useState } from "react"; +import { Method } from "../../model-editor/method"; +import { ModeledMethod } from "../../model-editor/modeled-method"; +import { styled } from "styled-components"; +import { MethodModelingInputs } from "./MethodModelingInputs"; +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; +import { Codicon } from "../common"; + +export type MultipleModeledMethodsPanelProps = { + method: Method; + modeledMethods: ModeledMethod[]; + onChange: (modeledMethod: ModeledMethod) => void; +}; + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 0.25rem; + + padding-bottom: 0.5rem; + border-bottom: 0.05rem solid var(--vscode-panelSection-border); +`; + +const Footer = styled.div` + display: flex; + flex-direction: row; +`; + +const PaginationActions = styled.div` + display: flex; + flex-direction: row; + gap: 0.5rem; +`; + +export const MultipleModeledMethodsPanel = ({ + method, + modeledMethods, + onChange, +}: MultipleModeledMethodsPanelProps) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const handlePreviousClick = useCallback(() => { + setSelectedIndex((previousIndex) => previousIndex - 1); + }, []); + const handleNextClick = useCallback(() => { + setSelectedIndex((previousIndex) => previousIndex + 1); + }, []); + + return ( + + {modeledMethods.length > 0 ? ( + + ) : ( + + )} +
+ + + + + {modeledMethods.length > 1 && ( +
+ {selectedIndex + 1}/{modeledMethods.length} +
+ )} + + + +
+
+
+ ); +}; diff --git a/extensions/ql-vscode/src/view/method-modeling/__tests__/MethodModeling.spec.tsx b/extensions/ql-vscode/src/view/method-modeling/__tests__/MethodModeling.spec.tsx index be625f743..0dad23970 100644 --- a/extensions/ql-vscode/src/view/method-modeling/__tests__/MethodModeling.spec.tsx +++ b/extensions/ql-vscode/src/view/method-modeling/__tests__/MethodModeling.spec.tsx @@ -16,7 +16,7 @@ describe(MethodModeling.name, () => { render({ modelingStatus: "saved", method, - modeledMethod, + modeledMethods: [modeledMethod], onChange, }); diff --git a/extensions/ql-vscode/src/view/method-modeling/__tests__/ModeledMethodsPanel.spec.tsx b/extensions/ql-vscode/src/view/method-modeling/__tests__/ModeledMethodsPanel.spec.tsx new file mode 100644 index 000000000..4bd0ce790 --- /dev/null +++ b/extensions/ql-vscode/src/view/method-modeling/__tests__/ModeledMethodsPanel.spec.tsx @@ -0,0 +1,74 @@ +import * as React from "react"; +import { render as reactRender, screen } from "@testing-library/react"; +import { createMethod } from "../../../../test/factories/model-editor/method-factories"; +import { createModeledMethod } from "../../../../test/factories/model-editor/modeled-method-factories"; +import { + ModeledMethodsPanel, + ModeledMethodsPanelProps, +} from "../ModeledMethodsPanel"; + +describe(ModeledMethodsPanel.name, () => { + const render = (props: ModeledMethodsPanelProps) => + reactRender(); + + const method = createMethod(); + const modeledMethods = [createModeledMethod(), createModeledMethod()]; + const onChange = jest.fn(); + + describe("when show multiple models is disabled", () => { + const showMultipleModels = false; + + it("renders the method modeling inputs", () => { + render({ + method, + modeledMethods, + onChange, + showMultipleModels, + }); + + expect(screen.getAllByRole("combobox")).toHaveLength(4); + }); + + it("does not render the pagination", () => { + render({ + method, + modeledMethods, + onChange, + showMultipleModels, + }); + + expect( + screen.queryByLabelText("Previous modeling"), + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Next modeling")).not.toBeInTheDocument(); + }); + }); + + describe("when show multiple models is enabled", () => { + const showMultipleModels = true; + + it("renders the method modeling inputs once", () => { + render({ + method, + modeledMethods, + onChange, + showMultipleModels, + }); + + expect(screen.getAllByRole("combobox")).toHaveLength(4); + }); + + it("renders the pagination", () => { + render({ + method, + modeledMethods, + onChange, + showMultipleModels, + }); + + expect(screen.getByLabelText("Previous modeling")).toBeInTheDocument(); + expect(screen.getByLabelText("Next modeling")).toBeInTheDocument(); + expect(screen.getByText("1/2")).toBeInTheDocument(); + }); + }); +}); diff --git a/extensions/ql-vscode/src/view/method-modeling/__tests__/MultipleModeledMethodsPanel.spec.tsx b/extensions/ql-vscode/src/view/method-modeling/__tests__/MultipleModeledMethodsPanel.spec.tsx new file mode 100644 index 000000000..995c06214 --- /dev/null +++ b/extensions/ql-vscode/src/view/method-modeling/__tests__/MultipleModeledMethodsPanel.spec.tsx @@ -0,0 +1,312 @@ +import * as React from "react"; +import { render as reactRender, screen, waitFor } from "@testing-library/react"; +import { createMethod } from "../../../../test/factories/model-editor/method-factories"; +import { createModeledMethod } from "../../../../test/factories/model-editor/modeled-method-factories"; +import { + MultipleModeledMethodsPanel, + MultipleModeledMethodsPanelProps, +} from "../MultipleModeledMethodsPanel"; +import userEvent from "@testing-library/user-event"; +import { ModeledMethod } from "../../../model-editor/modeled-method"; + +describe(MultipleModeledMethodsPanel.name, () => { + const render = (props: MultipleModeledMethodsPanelProps) => + reactRender(); + + const method = createMethod(); + const onChange = jest.fn(); + + describe("with no modeled methods", () => { + const modeledMethods: ModeledMethod[] = []; + + it("renders the method modeling inputs once", () => { + render({ + method, + modeledMethods, + onChange, + }); + + expect(screen.getAllByRole("combobox")).toHaveLength(4); + expect( + screen.getByRole("combobox", { + name: "Model type", + }), + ).toHaveValue("none"); + }); + + it("disables all pagination", () => { + render({ + method, + modeledMethods, + onChange, + }); + + expect( + screen + .getByLabelText("Previous modeling") + .getElementsByTagName("input")[0], + ).toBeDisabled(); + expect( + screen.getByLabelText("Next modeling").getElementsByTagName("input")[0], + ).toBeDisabled(); + expect(screen.queryByText("0/0")).not.toBeInTheDocument(); + expect(screen.queryByText("1/0")).not.toBeInTheDocument(); + }); + }); + + describe("with one modeled method", () => { + const modeledMethods = [ + createModeledMethod({ + ...method, + type: "sink", + input: "Argument[this]", + output: "", + kind: "path-injection", + }), + ]; + + it("renders the method modeling inputs once", () => { + render({ + method, + modeledMethods, + onChange, + }); + + expect(screen.getAllByRole("combobox")).toHaveLength(4); + expect( + screen.getByRole("combobox", { + name: "Model type", + }), + ).toHaveValue("sink"); + }); + + it("disables all pagination", () => { + render({ + method, + modeledMethods, + onChange, + }); + + expect( + screen + .getByLabelText("Previous modeling") + .getElementsByTagName("input")[0], + ).toBeDisabled(); + expect( + screen.getByLabelText("Next modeling").getElementsByTagName("input")[0], + ).toBeDisabled(); + expect(screen.queryByText("1/1")).not.toBeInTheDocument(); + }); + }); + + describe("with two modeled methods", () => { + const modeledMethods = [ + createModeledMethod({ + ...method, + type: "sink", + input: "Argument[this]", + output: "", + kind: "path-injection", + }), + createModeledMethod({ + ...method, + type: "source", + input: "", + output: "ReturnValue", + kind: "remote", + }), + ]; + + it("renders the method modeling inputs once", () => { + render({ + method, + modeledMethods, + onChange, + }); + + expect(screen.getAllByRole("combobox")).toHaveLength(4); + expect( + screen.getByRole("combobox", { + name: "Model type", + }), + ).toHaveValue("sink"); + }); + + it("renders the pagination", () => { + render({ + method, + modeledMethods, + onChange, + }); + + expect(screen.getByLabelText("Previous modeling")).toBeInTheDocument(); + expect(screen.getByLabelText("Next modeling")).toBeInTheDocument(); + expect(screen.getByText("1/2")).toBeInTheDocument(); + }); + + it("disables the correct pagination", async () => { + render({ + method, + modeledMethods, + onChange, + }); + + expect( + screen + .getByLabelText("Previous modeling") + .getElementsByTagName("input")[0], + ).toBeDisabled(); + expect( + screen.getByLabelText("Next modeling").getElementsByTagName("input")[0], + ).toBeEnabled(); + }); + + it("can use the pagination", async () => { + render({ + method, + modeledMethods, + onChange, + }); + + await userEvent.click(screen.getByLabelText("Next modeling")); + + await waitFor(() => { + expect( + screen + .getByLabelText("Previous modeling") + .getElementsByTagName("input")[0], + ).toBeEnabled(); + }); + + expect( + screen + .getByLabelText("Previous modeling") + .getElementsByTagName("input")[0], + ).toBeEnabled(); + expect( + screen.getByLabelText("Next modeling").getElementsByTagName("input")[0], + ).toBeDisabled(); + expect(screen.getByText("2/2")).toBeInTheDocument(); + + expect( + screen.getByRole("combobox", { + name: "Model type", + }), + ).toHaveValue("source"); + }); + }); + + describe("with three modeled methods", () => { + const modeledMethods = [ + createModeledMethod({ + ...method, + type: "sink", + input: "Argument[this]", + output: "", + kind: "path-injection", + }), + createModeledMethod({ + ...method, + type: "source", + input: "", + output: "ReturnValue", + kind: "remote", + }), + createModeledMethod({ + ...method, + type: "source", + input: "", + output: "ReturnValue", + kind: "local", + }), + ]; + + it("can use the pagination", async () => { + render({ + method, + modeledMethods, + onChange, + }); + + expect( + screen + .getByLabelText("Previous modeling") + .getElementsByTagName("input")[0], + ).toBeDisabled(); + expect( + screen.getByLabelText("Next modeling").getElementsByTagName("input")[0], + ).toBeEnabled(); + expect(screen.getByText("1/3")).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText("Next modeling")); + + await waitFor(() => { + expect( + screen + .getByLabelText("Previous modeling") + .getElementsByTagName("input")[0], + ).toBeEnabled(); + }); + + expect( + screen + .getByLabelText("Previous modeling") + .getElementsByTagName("input")[0], + ).toBeEnabled(); + expect( + screen.getByLabelText("Next modeling").getElementsByTagName("input")[0], + ).toBeEnabled(); + expect(screen.getByText("2/3")).toBeInTheDocument(); + + expect( + screen.getByRole("combobox", { + name: "Model type", + }), + ).toHaveValue("source"); + + await userEvent.click(screen.getByLabelText("Next modeling")); + + expect( + screen + .getByLabelText("Previous modeling") + .getElementsByTagName("input")[0], + ).toBeEnabled(); + expect( + screen.getByLabelText("Next modeling").getElementsByTagName("input")[0], + ).toBeDisabled(); + expect(screen.getByText("3/3")).toBeInTheDocument(); + + expect( + screen.getByRole("combobox", { + name: "Kind", + }), + ).toHaveValue("local"); + + await userEvent.click(screen.getByLabelText("Previous modeling")); + + await waitFor(() => { + expect( + screen + .getByLabelText("Next modeling") + .getElementsByTagName("input")[0], + ).toBeEnabled(); + }); + + expect( + screen + .getByLabelText("Previous modeling") + .getElementsByTagName("input")[0], + ).toBeEnabled(); + expect( + screen.getByLabelText("Next modeling").getElementsByTagName("input")[0], + ).toBeEnabled(); + expect(screen.getByText("2/3")).toBeInTheDocument(); + + expect( + screen.getByRole("combobox", { + name: "Kind", + }), + ).toHaveValue("remote"); + }); + }); +}); diff --git a/extensions/ql-vscode/src/view/model-editor/LibraryRow.tsx b/extensions/ql-vscode/src/view/model-editor/LibraryRow.tsx index 2c7871a69..29346210b 100644 --- a/extensions/ql-vscode/src/view/model-editor/LibraryRow.tsx +++ b/extensions/ql-vscode/src/view/model-editor/LibraryRow.tsx @@ -78,10 +78,7 @@ export type LibraryRowProps = { hideModeledMethods: boolean; revealedMethodSignature: string | null; onChange: (modeledMethod: ModeledMethod) => void; - onSaveModelClick: ( - methods: Method[], - modeledMethods: Record, - ) => void; + onSaveModelClick: (methodSignatures: string[]) => void; onGenerateFromLlmClick: ( dependencyName: string, methods: Method[], @@ -165,11 +162,11 @@ export const LibraryRow = ({ const handleSave = useCallback( async (e: React.MouseEvent) => { - onSaveModelClick(methods, modeledMethods); + onSaveModelClick(methods.map((m) => m.signature)); e.stopPropagation(); e.preventDefault(); }, - [methods, modeledMethods, onSaveModelClick], + [methods, onSaveModelClick], ); const hasUnsavedChanges = useMemo(() => { @@ -235,7 +232,7 @@ export const LibraryRow = ({ modeledMethods={modeledMethods} modifiedSignatures={modifiedSignatures} inProgressMethods={inProgressMethods} - mode={viewState.mode} + viewState={viewState} hideModeledMethods={hideModeledMethods} revealedMethodSignature={revealedMethodSignature} onChange={onChange} diff --git a/extensions/ql-vscode/src/view/model-editor/MethodRow.tsx b/extensions/ql-vscode/src/view/model-editor/MethodRow.tsx index b1880355c..dc8581c7d 100644 --- a/extensions/ql-vscode/src/view/model-editor/MethodRow.tsx +++ b/extensions/ql-vscode/src/view/model-editor/MethodRow.tsx @@ -21,8 +21,16 @@ import { MethodName } from "./MethodName"; import { ModelTypeDropdown } from "./ModelTypeDropdown"; import { ModelInputDropdown } from "./ModelInputDropdown"; import { ModelOutputDropdown } from "./ModelOutputDropdown"; +import { ModelEditorViewState } from "../../model-editor/shared/view-state"; -const ApiOrMethodCell = styled(VSCodeDataGridCell)` +const MultiModelColumn = styled(VSCodeDataGridCell)` + display: flex; + flex-direction: column; + gap: 0.5em; +`; + +const ApiOrMethodRow = styled.div` + min-height: calc(var(--input-height) * 1px); display: flex; flex-direction: row; align-items: center; @@ -55,10 +63,10 @@ const DataGridRow = styled(VSCodeDataGridRow)<{ focused?: boolean }>` export type MethodRowProps = { method: Method; methodCanBeModeled: boolean; - modeledMethod: ModeledMethod | undefined; + modeledMethods: ModeledMethod[]; methodIsUnsaved: boolean; modelingInProgress: boolean; - mode: Mode; + viewState: ModelEditorViewState; revealedMethodSignature: string | null; onChange: (modeledMethod: ModeledMethod) => void; }; @@ -88,19 +96,23 @@ const ModelableMethodRow = forwardRef( (props, ref) => { const { method, - modeledMethod, + modeledMethods: modeledMethodsProp, methodIsUnsaved, - mode, + viewState, revealedMethodSignature, onChange, } = props; + const modeledMethods = viewState.showMultipleModels + ? modeledMethodsProp + : modeledMethodsProp.slice(0, 1); + const jumpToUsage = useCallback( () => sendJumpToUsageMessage(method), [method], ); - const modelingStatus = getModelingStatus(modeledMethod, methodIsUnsaved); + const modelingStatus = getModelingStatus(modeledMethods, methodIsUnsaved); return ( ( ref={ref} focused={revealedMethodSignature === method.signature} > - - - - - {mode === Mode.Application && ( - - {method.usages.length} - - )} - View - {props.modelingInProgress && } - + + + + + + {viewState.mode === Mode.Application && ( + + {method.usages.length} + + )} + View + {props.modelingInProgress && } + + {props.modelingInProgress && ( <> @@ -138,34 +152,46 @@ const ModelableMethodRow = forwardRef( )} {!props.modelingInProgress && ( <> - - - - - - - - - - - - + + {forEachModeledMethod(modeledMethods, (modeledMethod, index) => ( + + ))} + + + {forEachModeledMethod(modeledMethods, (modeledMethod, index) => ( + + ))} + + + {forEachModeledMethod(modeledMethods, (modeledMethod, index) => ( + + ))} + + + {forEachModeledMethod(modeledMethods, (modeledMethod, index) => ( + + ))} + )} @@ -178,7 +204,7 @@ const UnmodelableMethodRow = forwardRef< HTMLElement | undefined, MethodRowProps >((props, ref) => { - const { method, mode, revealedMethodSignature } = props; + const { method, viewState, revealedMethodSignature } = props; const jumpToUsage = useCallback( () => sendJumpToUsageMessage(method), @@ -191,17 +217,19 @@ const UnmodelableMethodRow = forwardRef< ref={ref} focused={revealedMethodSignature === method.signature} > - - - - {mode === Mode.Application && ( - - {method.usages.length} - - )} - View - - + + + + + {viewState.mode === Mode.Application && ( + + {method.usages.length} + + )} + View + + + Method already modeled @@ -218,3 +246,17 @@ function sendJumpToUsageMessage(method: Method) { usage: method.usages[0], }); } + +function forEachModeledMethod( + modeledMethods: ModeledMethod[], + renderer: ( + modeledMethod: ModeledMethod | undefined, + index: number, + ) => JSX.Element, +): JSX.Element | JSX.Element[] { + if (modeledMethods.length === 0) { + return renderer(undefined, 0); + } else { + return modeledMethods.map(renderer); + } +} diff --git a/extensions/ql-vscode/src/view/model-editor/ModelEditor.tsx b/extensions/ql-vscode/src/view/model-editor/ModelEditor.tsx index 1f130ee60..19dbf53ea 100644 --- a/extensions/ql-vscode/src/view/model-editor/ModelEditor.tsx +++ b/extensions/ql-vscode/src/view/model-editor/ModelEditor.tsx @@ -142,7 +142,7 @@ export function ModelEditor({ ); break; case "revealMethod": - setRevealedMethodSignature(msg.method.signature); + setRevealedMethodSignature(msg.methodSignature); break; default: @@ -196,21 +196,15 @@ export function ModelEditor({ const onSaveAllClick = useCallback(() => { vscode.postMessage({ t: "saveModeledMethods", - methods, - modeledMethods, }); - }, [methods, modeledMethods]); + }, []); - const onSaveModelClick = useCallback( - (methods: Method[], modeledMethods: Record) => { - vscode.postMessage({ - t: "saveModeledMethods", - methods, - modeledMethods, - }); - }, - [], - ); + const onSaveModelClick = useCallback((methodSignatures: string[]) => { + vscode.postMessage({ + t: "saveModeledMethods", + methodSignatures, + }); + }, []); const onGenerateFromSourceClick = useCallback(() => { vscode.postMessage({ diff --git a/extensions/ql-vscode/src/view/model-editor/ModeledMethodDataGrid.tsx b/extensions/ql-vscode/src/view/model-editor/ModeledMethodDataGrid.tsx index 11044c5ff..bf53b479c 100644 --- a/extensions/ql-vscode/src/view/model-editor/ModeledMethodDataGrid.tsx +++ b/extensions/ql-vscode/src/view/model-editor/ModeledMethodDataGrid.tsx @@ -8,10 +8,10 @@ import { MethodRow } from "./MethodRow"; import { Method } from "../../model-editor/method"; import { ModeledMethod } from "../../model-editor/modeled-method"; import { useMemo } from "react"; -import { Mode } from "../../model-editor/shared/mode"; import { sortMethods } from "../../model-editor/shared/sorting"; import { InProgressMethods } from "../../model-editor/shared/in-progress-methods"; import { HiddenMethodsRow } from "./HiddenMethodsRow"; +import { ModelEditorViewState } from "../../model-editor/shared/view-state"; export const GRID_TEMPLATE_COLUMNS = "0.5fr 0.125fr 0.125fr 0.125fr 0.125fr"; @@ -21,7 +21,7 @@ export type ModeledMethodDataGridProps = { modeledMethods: Record; modifiedSignatures: Set; inProgressMethods: InProgressMethods; - mode: Mode; + viewState: ModelEditorViewState; hideModeledMethods: boolean; revealedMethodSignature: string | null; onChange: (modeledMethod: ModeledMethod) => void; @@ -33,7 +33,7 @@ export const ModeledMethodDataGrid = ({ modeledMethods, modifiedSignatures, inProgressMethods, - mode, + viewState, hideModeledMethods, revealedMethodSignature, onChange, @@ -84,22 +84,25 @@ export const ModeledMethodDataGrid = ({ Kind - {methodsWithModelability.map(({ method, methodCanBeModeled }) => ( - - ))} + {methodsWithModelability.map(({ method, methodCanBeModeled }) => { + const modeledMethod = modeledMethods[method.signature]; + return ( + + ); + })} )} void; - onSaveModelClick: ( - methods: Method[], - modeledMethods: Record, - ) => void; + onSaveModelClick: (methodSignatures: string[]) => void; onGenerateFromLlmClick: ( packageName: string, methods: Method[], diff --git a/extensions/ql-vscode/src/view/model-editor/__tests__/MethodRow.spec.tsx b/extensions/ql-vscode/src/view/model-editor/__tests__/MethodRow.spec.tsx index 2c22455c0..4f34ab1e7 100644 --- a/extensions/ql-vscode/src/view/model-editor/__tests__/MethodRow.spec.tsx +++ b/extensions/ql-vscode/src/view/model-editor/__tests__/MethodRow.spec.tsx @@ -9,6 +9,8 @@ import { Mode } from "../../../model-editor/shared/mode"; import { MethodRow, MethodRowProps } from "../MethodRow"; import { ModeledMethod } from "../../../model-editor/modeled-method"; import userEvent from "@testing-library/user-event"; +import { ModelEditorViewState } from "../../../model-editor/shared/view-state"; +import { createMockExtensionPack } from "../../../../test/factories/model-editor/extension-pack"; describe(MethodRow.name, () => { const method = createMethod({ @@ -31,16 +33,24 @@ describe(MethodRow.name, () => { }; const onChange = jest.fn(); + const viewState: ModelEditorViewState = { + mode: Mode.Application, + showFlowGeneration: false, + showLlmButton: false, + showMultipleModels: false, + extensionPack: createMockExtensionPack(), + }; + const render = (props: Partial = {}) => reactRender( , @@ -54,6 +64,14 @@ describe(MethodRow.name, () => { expect(screen.queryByLabelText("Loading")).not.toBeInTheDocument(); }); + it("renders when there is no modeled method", () => { + render({ modeledMethods: [] }); + + expect(screen.queryAllByRole("combobox")).toHaveLength(4); + expect(screen.getByLabelText("Method not modeled")).toBeInTheDocument(); + expect(screen.queryByLabelText("Loading")).not.toBeInTheDocument(); + }); + it("can change the kind", async () => { render(); @@ -110,7 +128,7 @@ describe(MethodRow.name, () => { it("shows the modeling status indicator when unmodeled", () => { render({ - modeledMethod: undefined, + modeledMethods: [], }); expect(screen.getByLabelText("Method not modeled")).toBeInTheDocument(); @@ -124,10 +142,48 @@ describe(MethodRow.name, () => { expect(screen.getByLabelText("Loading")).toBeInTheDocument(); }); + it("can render multiple models", () => { + render({ + modeledMethods: [ + { ...modeledMethod, type: "source" }, + { ...modeledMethod, type: "sink" }, + { ...modeledMethod, type: "summary" }, + ], + viewState: { + ...viewState, + showMultipleModels: true, + }, + }); + + const kindInputs = screen.getAllByRole("combobox", { name: "Model type" }); + expect(kindInputs).toHaveLength(3); + expect(kindInputs[0]).toHaveValue("source"); + expect(kindInputs[1]).toHaveValue("sink"); + expect(kindInputs[2]).toHaveValue("summary"); + }); + + it("renders only first model when showMultipleModels feature flag is disabled", () => { + render({ + modeledMethods: [ + { ...modeledMethod, type: "source" }, + { ...modeledMethod, type: "sink" }, + { ...modeledMethod, type: "summary" }, + ], + viewState: { + ...viewState, + showMultipleModels: false, + }, + }); + + const kindInputs = screen.getAllByRole("combobox", { name: "Model type" }); + expect(kindInputs.length).toBe(1); + expect(kindInputs[0]).toHaveValue("source"); + }); + it("renders an unmodelable method", () => { render({ methodCanBeModeled: false, - modeledMethod: undefined, + modeledMethods: [], }); expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); diff --git a/extensions/ql-vscode/src/view/model-editor/__tests__/ModelKindDropdown.spec.tsx b/extensions/ql-vscode/src/view/model-editor/__tests__/ModelKindDropdown.spec.tsx index 3b0bf89ff..9db022104 100644 --- a/extensions/ql-vscode/src/view/model-editor/__tests__/ModelKindDropdown.spec.tsx +++ b/extensions/ql-vscode/src/view/model-editor/__tests__/ModelKindDropdown.spec.tsx @@ -57,6 +57,7 @@ describe(ModelKindDropdown.name, () => { // Changing the type to sink should update the supported kinds const updatedModeledMethod = createModeledMethod({ type: "sink", + kind: "local", }); rerender( diff --git a/extensions/ql-vscode/src/view/model-editor/__tests__/ModeledMethodDataGrid.spec.tsx b/extensions/ql-vscode/src/view/model-editor/__tests__/ModeledMethodDataGrid.spec.tsx index 25cb0268c..a9ae87ba6 100644 --- a/extensions/ql-vscode/src/view/model-editor/__tests__/ModeledMethodDataGrid.spec.tsx +++ b/extensions/ql-vscode/src/view/model-editor/__tests__/ModeledMethodDataGrid.spec.tsx @@ -7,6 +7,8 @@ import { ModeledMethodDataGrid, ModeledMethodDataGridProps, } from "../ModeledMethodDataGrid"; +import { ModelEditorViewState } from "../../../model-editor/shared/view-state"; +import { createMockExtensionPack } from "../../../../test/factories/model-editor/extension-pack"; describe(ModeledMethodDataGrid.name, () => { const method1 = createMethod({ @@ -41,6 +43,14 @@ describe(ModeledMethodDataGrid.name, () => { }); const onChange = jest.fn(); + const viewState: ModelEditorViewState = { + mode: Mode.Application, + showFlowGeneration: false, + showLlmButton: false, + showMultipleModels: false, + extensionPack: createMockExtensionPack(), + }; + const render = (props: Partial = {}) => reactRender( { }} modifiedSignatures={new Set([method1.signature])} inProgressMethods={new InProgressMethods()} - mode={Mode.Application} + viewState={viewState} hideModeledMethods={false} revealedMethodSignature={null} onChange={onChange} diff --git a/extensions/ql-vscode/syntaxes/ql.tmLanguage.yml b/extensions/ql-vscode/syntaxes/ql.tmLanguage.yml index d7f8ac071..fe10a86d0 100644 --- a/extensions/ql-vscode/syntaxes/ql.tmLanguage.yml +++ b/extensions/ql-vscode/syntaxes/ql.tmLanguage.yml @@ -170,6 +170,14 @@ repository: match: '\]' name: punctuation.squarebracket.close.ql + open-angle: + match: '<' + name: punctuation.anglebracket.open.ql + + close-angle: + match: '>' + name: punctuation.anglebracket.close.ql + operator-or-punctuation: patterns: - include: '#relational-operator' @@ -186,6 +194,8 @@ repository: - include: '#close-brace' - include: '#open-bracket' - include: '#close-bracket' + - include: '#open-angle' + - include: '#close-angle' # Keywords dont-care: @@ -651,18 +661,36 @@ repository: - include: '#non-context-sensitive' - include: '#annotation' + # The argument list of an instantiation, enclosed in angle brackets. + instantiation-args: + beginPattern: '#open-angle' + endPattern: '#close-angle' + name: meta.type.parameters.ql + patterns: + # Include `#instantiation-args` first so that `#open-angle` and `#close-angle` take precedence + # over `#relational-operator`. + - include: '#instantiation-args' + - include: '#non-context-sensitive' + - match: '(?#simple-id)' + name: entity.name.type.namespace.ql + # An `import` directive. Note that we parse the optional `as` clause as a separate top-level # directive, because otherwise it's too hard to figure out where the `import` directive ends. import-directive: beginPattern: '#import' - # Ends with a simple-id that is not followed by a `.` or a `::`. This does not handle comments or - # line breaks between the simple-id and the `.` or `::`. - end: '(?#simple-id) (?!\s*(\.|\:\:))' - endCaptures: - '0': - name: entity.name.type.namespace.ql + # TextMate makes it tricky to tell whether an identifier that we encounter is part of the + # `import` directive or whether it's the first token of the next module-level declaration. + # To find the end of the import directive, we'll look for a zero-width match where the previous + # token is either an identifier (other than `import`) or a `>`, and the next token is not a `.`, + # `<`, `,`, or `::`. This works for nearly all real-world `import` directives, but it will end the + # `import` directive too early if there is a comment or line break between two components of the + # module expression. + end: '(?)|[A-Za-z0-9_]) (?!\s*(\.|\:\:|\,|(?#open-angle)))' name: meta.block.import-directive.ql patterns: + # Include `#instantiation-args` first so that `#open-angle` and `#close-angle` take precedence + # over `#relational-operator`. + - include: '#instantiation-args' - include: '#non-context-sensitive' - match: '(?#simple-id)' name: entity.name.type.namespace.ql @@ -703,7 +731,6 @@ repository: - match: '(?#simple-id)|(?#at-lower-id)' name: entity.name.type.ql - # A `module` declaration, whether a module definition or an alias declaration. module-declaration: # Starts with the `module` keyword. diff --git a/extensions/ql-vscode/test/factories/model-editor/modeled-method-factories.ts b/extensions/ql-vscode/test/factories/model-editor/modeled-method-factories.ts index 0fff7d480..9c8b1207b 100644 --- a/extensions/ql-vscode/test/factories/model-editor/modeled-method-factories.ts +++ b/extensions/ql-vscode/test/factories/model-editor/modeled-method-factories.ts @@ -13,7 +13,7 @@ export function createModeledMethod( type: "sink", input: "Argument[0]", output: "", - kind: "jndi-injection", + kind: "path-injection", provenance: "manual", ...data, }; diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/extension-pack-picker.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/extension-pack-picker.test.ts index 4ae3415ba..6898a32cc 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/extension-pack-picker.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/extension-pack-picker.test.ts @@ -1,10 +1,4 @@ -import { - ConfigurationScope, - Uri, - workspace, - WorkspaceConfiguration as VSCodeWorkspaceConfiguration, - WorkspaceFolder, -} from "vscode"; +import { Uri, workspace, WorkspaceFolder } from "vscode"; import { dump as dumpYaml, load as loadYaml } from "js-yaml"; import { outputFile, readFile } from "fs-extra"; import { join } from "path"; @@ -14,7 +8,8 @@ import { QlpacksInfo } from "../../../../src/codeql-cli/cli"; import { pickExtensionPack } from "../../../../src/model-editor/extension-pack-picker"; import { ExtensionPack } from "../../../../src/model-editor/shared/extension-pack"; import { createMockLogger } from "../../../__mocks__/loggerMock"; -import { vscodeGetConfigurationMock } from "../../test-config"; +import { ModelConfig } from "../../../../src/config"; +import { mockedObject } from "../../utils/mocking.helpers"; describe("pickExtensionPack", () => { let tmpDir: string; @@ -32,6 +27,7 @@ describe("pickExtensionPack", () => { let workspaceFoldersSpy: jest.SpyInstance; let additionalPacks: string[]; let workspaceFolder: WorkspaceFolder; + let modelConfig: ModelConfig; const logger = createMockLogger(); const maxStep = 4; @@ -67,41 +63,20 @@ describe("pickExtensionPack", () => { workspaceFoldersSpy = jest .spyOn(workspace, "workspaceFolders", "get") .mockReturnValue([workspaceFolder]); + + modelConfig = mockedObject({ + getExtensionsDirectory: jest.fn().mockReturnValue(undefined), + }); }); it("selects an existing extension pack", async () => { - vscodeGetConfigurationMock.mockImplementation( - ( - section?: string, - scope?: ConfigurationScope | null, - ): VSCodeWorkspaceConfiguration => { - expect(section).toEqual("codeQL.model"); - expect((scope as any)?.languageId).toEqual("java"); - - return { - get: (key: string) => { - expect(key).toEqual("extensionsDirectory"); - return undefined; - }, - has: (key: string) => { - return key === "extensionsDirectory"; - }, - inspect: () => { - throw new Error("inspect not implemented"); - }, - update: () => { - throw new Error("update not implemented"); - }, - }; - }, - ); - const cliServer = mockCliServer(qlPacks); expect( await pickExtensionPack( cliServer, databaseItem, + modelConfig, logger, progress, maxStep, @@ -112,35 +87,10 @@ describe("pickExtensionPack", () => { additionalPacks, true, ); + expect(modelConfig.getExtensionsDirectory).toHaveBeenCalledWith("java"); }); it("creates a new extension pack using default extensions directory", async () => { - vscodeGetConfigurationMock.mockImplementation( - ( - section?: string, - scope?: ConfigurationScope | null, - ): VSCodeWorkspaceConfiguration => { - expect(section).toEqual("codeQL.model"); - expect((scope as any)?.languageId).toEqual("java"); - - return { - get: (key: string) => { - expect(key).toEqual("extensionsDirectory"); - return undefined; - }, - has: (key: string) => { - return key === "extensionsDirectory"; - }, - inspect: () => { - throw new Error("inspect not implemented"); - }, - update: () => { - throw new Error("update not implemented"); - }, - }; - }, - ); - const tmpDir = await dir({ unsafeCleanup: true, }); @@ -183,6 +133,7 @@ describe("pickExtensionPack", () => { await pickExtensionPack( cliServer, databaseItem, + modelConfig, logger, progress, maxStep, @@ -199,6 +150,7 @@ describe("pickExtensionPack", () => { dataExtensions: ["models/**/*.yml"], }); expect(cliServer.resolveQlpacks).toHaveBeenCalled(); + expect(modelConfig.getExtensionsDirectory).toHaveBeenCalledWith("java"); expect( loadYaml(await readFile(join(newPackDir, "codeql-pack.yml"), "utf8")), @@ -223,31 +175,9 @@ describe("pickExtensionPack", () => { "my-custom-extensions-directory", ); - vscodeGetConfigurationMock.mockImplementation( - ( - section?: string, - scope?: ConfigurationScope | null, - ): VSCodeWorkspaceConfiguration => { - expect(section).toEqual("codeQL.model"); - expect((scope as any)?.languageId).toEqual("java"); - - return { - get: (key: string) => { - expect(key).toEqual("extensionsDirectory"); - return configExtensionsDir; - }, - has: (key: string) => { - return key === "extensionsDirectory"; - }, - inspect: () => { - throw new Error("inspect not implemented"); - }, - update: () => { - throw new Error("update not implemented"); - }, - }; - }, - ); + const modelConfig = mockedObject({ + getExtensionsDirectory: jest.fn().mockReturnValue(configExtensionsDir), + }); const newPackDir = join(configExtensionsDir, "vscode-codeql-java"); @@ -257,6 +187,7 @@ describe("pickExtensionPack", () => { await pickExtensionPack( cliServer, databaseItem, + modelConfig, logger, progress, maxStep, @@ -273,6 +204,7 @@ describe("pickExtensionPack", () => { dataExtensions: ["models/**/*.yml"], }); expect(cliServer.resolveQlpacks).toHaveBeenCalled(); + expect(modelConfig.getExtensionsDirectory).toHaveBeenCalledWith("java"); expect( loadYaml(await readFile(join(newPackDir, "codeql-pack.yml"), "utf8")), @@ -299,6 +231,7 @@ describe("pickExtensionPack", () => { await pickExtensionPack( cliServer, databaseItem, + modelConfig, logger, progress, maxStep, @@ -324,6 +257,7 @@ describe("pickExtensionPack", () => { await pickExtensionPack( cliServer, databaseItem, + modelConfig, logger, progress, maxStep, @@ -351,6 +285,7 @@ describe("pickExtensionPack", () => { await pickExtensionPack( cliServer, databaseItem, + modelConfig, logger, progress, maxStep, @@ -388,6 +323,7 @@ describe("pickExtensionPack", () => { await pickExtensionPack( cliServer, databaseItem, + modelConfig, logger, progress, maxStep, @@ -425,6 +361,7 @@ describe("pickExtensionPack", () => { await pickExtensionPack( cliServer, databaseItem, + modelConfig, logger, progress, maxStep, @@ -465,6 +402,7 @@ describe("pickExtensionPack", () => { await pickExtensionPack( cliServer, databaseItem, + modelConfig, logger, progress, maxStep, @@ -522,6 +460,7 @@ describe("pickExtensionPack", () => { await pickExtensionPack( cliServer, databaseItem, + modelConfig, logger, progress, maxStep, diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/model-editor-queries.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/model-editor-queries.test.ts index f6d827b3e..ccbafc186 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/model-editor-queries.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/model-editor-queries.test.ts @@ -8,6 +8,7 @@ import { QueryLanguage } from "../../../../src/common/query-language"; import { Mode } from "../../../../src/model-editor/shared/mode"; import { mockedObject } from "../../utils/mocking.helpers"; import { CodeQLCliServer } from "../../../../src/codeql-cli/cli"; +import { ModelConfig } from "../../../../src/config"; describe("setUpPack", () => { let queryDir: string; @@ -32,8 +33,11 @@ describe("setUpPack", () => { packInstall: jest.fn(), resolveQueriesInSuite: jest.fn().mockResolvedValue([]), }); + const modelConfig = mockedObject({ + llmGeneration: false, + }); - await setUpPack(cliServer, queryDir, language); + await setUpPack(cliServer, queryDir, language, modelConfig); const queryFiles = await readdir(queryDir); expect(queryFiles.sort()).toEqual( @@ -89,8 +93,11 @@ describe("setUpPack", () => { .fn() .mockResolvedValue(["/a/b/c/ApplicationModeEndpoints.ql"]), }); + const modelConfig = mockedObject({ + llmGeneration: false, + }); - await setUpPack(cliServer, queryDir, language); + await setUpPack(cliServer, queryDir, language, modelConfig); const queryFiles = await readdir(queryDir); expect(queryFiles.sort()).toEqual(["codeql-pack.yml"].sort()); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/model-editor-view.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/model-editor-view.test.ts index 2abb9177a..b346de2e0 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/model-editor-view.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/model-editor-view.test.ts @@ -10,11 +10,15 @@ import { QueryRunner } from "../../../../src/query-server"; import { ExtensionPack } from "../../../../src/model-editor/shared/extension-pack"; import { createMockModelingStore } from "../../../__mocks__/model-editor/modelingStoreMock"; import { createMockModelEditorViewTracker } from "../../../__mocks__/model-editor/modelEditorViewTrackerMock"; +import { ModelConfigListener } from "../../../../src/config"; describe("ModelEditorView", () => { const app = createMockApp({}); const modelingStore = createMockModelingStore(); const viewTracker = createMockModelEditorViewTracker(); + const modelConfig = mockedObject({ + onDidChangeConfiguration: jest.fn(), + }); const databaseManager = mockEmptyDatabaseManager(); const cliServer = mockedObject({}); const queryRunner = mockedObject({}); @@ -41,6 +45,7 @@ describe("ModelEditorView", () => { app, modelingStore, viewTracker, + modelConfig, databaseManager, cliServer, queryRunner, diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/telemetry.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/telemetry.test.ts index d8d855494..f07cc4e61 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/telemetry.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/telemetry.test.ts @@ -4,6 +4,7 @@ import { workspace, ConfigurationTarget, window, + env, } from "vscode"; import { ExtensionTelemetryListener, @@ -30,13 +31,18 @@ describe("telemetry reporting", () => { let sendTelemetryEventSpy: jest.SpiedFunction< typeof TelemetryReporter.prototype.sendTelemetryEvent >; - let sendTelemetryExceptionSpy: jest.SpiedFunction< - typeof TelemetryReporter.prototype.sendTelemetryException + let sendTelemetryErrorEventSpy: jest.SpiedFunction< + typeof TelemetryReporter.prototype.sendTelemetryErrorEvent >; let disposeSpy: jest.SpiedFunction< typeof TelemetryReporter.prototype.dispose >; + let isTelemetryEnabledSpy: jest.SpyInstance< + typeof env.isTelemetryEnabled, + [] + >; + let showInformationMessageSpy: jest.SpiedFunction< typeof window.showInformationMessage >; @@ -56,8 +62,8 @@ describe("telemetry reporting", () => { sendTelemetryEventSpy = jest .spyOn(TelemetryReporter.prototype, "sendTelemetryEvent") .mockReturnValue(undefined); - sendTelemetryExceptionSpy = jest - .spyOn(TelemetryReporter.prototype, "sendTelemetryException") + sendTelemetryErrorEventSpy = jest + .spyOn(TelemetryReporter.prototype, "sendTelemetryErrorEvent") .mockReturnValue(undefined); disposeSpy = jest .spyOn(TelemetryReporter.prototype, "dispose") @@ -78,6 +84,9 @@ describe("telemetry reporting", () => { .get("codeQL.canary")).toString(); // each test will default to telemetry being enabled + isTelemetryEnabledSpy = jest + .spyOn(env, "isTelemetryEnabled", "get") + .mockReturnValue(true); await enableTelemetry("telemetry", true); await enableTelemetry("codeQL.telemetry", true); @@ -116,6 +125,7 @@ describe("telemetry reporting", () => { }); it("should initialize telemetry when global option disabled", async () => { + isTelemetryEnabledSpy.mockReturnValue(false); await enableTelemetry("telemetry", false); await telemetryListener.initialize(); expect(telemetryListener._reporter).toBeDefined(); @@ -133,6 +143,7 @@ describe("telemetry reporting", () => { it("should not initialize telemetry when both options disabled", async () => { await enableTelemetry("codeQL.telemetry", false); + isTelemetryEnabledSpy.mockReturnValue(false); await enableTelemetry("telemetry", false); await telemetryListener.initialize(); expect(telemetryListener._reporter).toBeUndefined(); @@ -179,6 +190,7 @@ describe("telemetry reporting", () => { const reporter: any = telemetryListener._reporter; expect(reporter.userOptIn).toBe(true); // enabled + isTelemetryEnabledSpy.mockReturnValue(false); await enableTelemetry("telemetry", false); expect(reporter.userOptIn).toBe(false); // disabled }); @@ -198,8 +210,7 @@ describe("telemetry reporting", () => { }, { executionTime: 1234 }, ); - - expect(sendTelemetryExceptionSpy).not.toBeCalled(); + expect(sendTelemetryErrorEventSpy).not.toBeCalled(); }); it("should send a command usage event with an error", async () => { @@ -221,8 +232,7 @@ describe("telemetry reporting", () => { }, { executionTime: 1234 }, ); - - expect(sendTelemetryExceptionSpy).not.toBeCalled(); + expect(sendTelemetryErrorEventSpy).not.toBeCalled(); }); it("should send a command usage event with a cli version", async () => { @@ -245,8 +255,7 @@ describe("telemetry reporting", () => { }, { executionTime: 1234 }, ); - - expect(sendTelemetryExceptionSpy).not.toBeCalled(); + expect(sendTelemetryErrorEventSpy).not.toBeCalled(); // Verify that if the cli version is not set, then the telemetry falls back to "not-set" sendTelemetryEventSpy.mockClear(); @@ -268,6 +277,7 @@ describe("telemetry reporting", () => { }, { executionTime: 5678 }, ); + expect(sendTelemetryErrorEventSpy).not.toBeCalled(); }); it("should avoid sending an event when telemetry is disabled", async () => { @@ -278,7 +288,7 @@ describe("telemetry reporting", () => { telemetryListener.sendCommandUsage("command-id", 1234, new Error()); expect(sendTelemetryEventSpy).not.toBeCalled(); - expect(sendTelemetryExceptionSpy).not.toBeCalled(); + expect(sendTelemetryErrorEventSpy).not.toBeCalled(); }); it("should send an event when telemetry is re-enabled", async () => { @@ -298,6 +308,7 @@ describe("telemetry reporting", () => { }, { executionTime: 1234 }, ); + expect(sendTelemetryErrorEventSpy).not.toBeCalled(); }); it("should filter undesired properties from telemetry payload", async () => { @@ -345,6 +356,8 @@ describe("telemetry reporting", () => { resolveArg(3 /* "yes" item */), ); await ctx.globalState.update("telemetry-request-viewed", false); + expect(env.isTelemetryEnabled).toBe(true); + await enableTelemetry("codeQL.telemetry", false); await telemetryListener.initialize(); @@ -411,6 +424,7 @@ describe("telemetry reporting", () => { // If the user ever turns global telemetry back on, then we can // show the dialog. + isTelemetryEnabledSpy.mockReturnValue(false); await enableTelemetry("telemetry", false); await ctx.globalState.update("telemetry-request-viewed", false); @@ -455,6 +469,7 @@ describe("telemetry reporting", () => { }, {}, ); + expect(sendTelemetryErrorEventSpy).not.toBeCalled(); }); it("should send a ui-interaction telementry event with a cli version", async () => { @@ -472,6 +487,7 @@ describe("telemetry reporting", () => { }, {}, ); + expect(sendTelemetryErrorEventSpy).not.toBeCalled(); }); it("should send an error telementry event", async () => { @@ -479,7 +495,8 @@ describe("telemetry reporting", () => { telemetryListener.sendError(redactableError`test`); - expect(sendTelemetryEventSpy).toHaveBeenCalledWith( + expect(sendTelemetryEventSpy).not.toBeCalled(); + expect(sendTelemetryErrorEventSpy).toHaveBeenCalledWith( "error", { message: "test", @@ -497,7 +514,8 @@ describe("telemetry reporting", () => { telemetryListener.sendError(redactableError`test`); - expect(sendTelemetryEventSpy).toHaveBeenCalledWith( + expect(sendTelemetryEventSpy).not.toBeCalled(); + expect(sendTelemetryErrorEventSpy).toHaveBeenCalledWith( "error", { message: "test", @@ -516,7 +534,8 @@ describe("telemetry reporting", () => { redactableError`test message with secret information: ${42} and more ${"secret"} parts`, ); - expect(sendTelemetryEventSpy).toHaveBeenCalledWith( + expect(sendTelemetryEventSpy).not.toBeCalled(); + expect(sendTelemetryErrorEventSpy).toHaveBeenCalledWith( "error", { message: diff --git a/syntaxes/ql.tmLanguage.json b/syntaxes/ql.tmLanguage.json index 66e1cc8ec..a8b5c3909 100644 --- a/syntaxes/ql.tmLanguage.json +++ b/syntaxes/ql.tmLanguage.json @@ -108,6 +108,14 @@ "match": "(?x)\\]", "name": "punctuation.squarebracket.close.ql" }, + "open-angle": { + "match": "(?x)<", + "name": "punctuation.anglebracket.open.ql" + }, + "close-angle": { + "match": "(?x)>", + "name": "punctuation.anglebracket.close.ql" + }, "operator-or-punctuation": { "patterns": [ { @@ -151,6 +159,12 @@ }, { "include": "#close-bracket" + }, + { + "include": "#open-angle" + }, + { + "include": "#close-angle" } ] }, @@ -661,9 +675,9 @@ "begin": "(?x)(?<=/\\*\\*)([^*]|\\*(?!/))*$", "while": "(?x)(^|\\G)\\s*([^*]|\\*(?!/))(?=([^*]|[*](?!/))*$)", "patterns": [ - - - + + + { "match": "(?x)\\G\\s* (@\\S+)", "name": "keyword.tag.ql" @@ -723,15 +737,48 @@ } ] }, - "import-directive": { - "end": "(?x)(?:\\b [A-Za-z][0-9A-Za-z_]* (?:(?!(?:[0-9A-Za-z_])))) (?!\\s*(\\.|\\:\\:))", - "endCaptures": { - "0": { + "instantiation-args": { + "name": "meta.type.parameters.ql", + "patterns": [ + { + "include": "#instantiation-args" + }, + { + "include": "#non-context-sensitive" + }, + { + "match": "(?x)(?:\\b [A-Za-z][0-9A-Za-z_]* (?:(?!(?:[0-9A-Za-z_]))))", "name": "entity.name.type.namespace.ql" } + ], + "begin": "(?x)((?:<))", + "beginCaptures": { + "1": { + "patterns": [ + { + "include": "#open-angle" + } + ] + } }, + "end": "(?x)((?:>))", + "endCaptures": { + "1": { + "patterns": [ + { + "include": "#close-angle" + } + ] + } + } + }, + "import-directive": { + "end": "(?x)(?)|[A-Za-z0-9_]) (?!\\s*(\\.|\\:\\:|\\,|(?:<)))", "name": "meta.block.import-directive.ql", "patterns": [ + { + "include": "#instantiation-args" + }, { "include": "#non-context-sensitive" }, @@ -1493,4 +1540,4 @@ "name": "constant.character.escape.ql" } } -} \ No newline at end of file +}