QuickEval

This commit is contained in:
Dave Bartolomeo
2023-04-04 17:43:04 -04:00
parent 65b0cb4dc4
commit d489d0ec1f
12 changed files with 556 additions and 189 deletions

View File

@@ -494,6 +494,14 @@
"command": "codeQL.getCurrentQuery",
"title": "CodeQL: Get Current Query"
},
{
"command": "codeQL.debug.quickEval",
"title": "CodeQL Debugger: Quick Evaluation"
},
{
"command": "codeQL.debug.quickEvalContextEditor",
"title": "CodeQL Debugger: Quick Evaluation"
},
{
"command": "codeQL.viewAst",
"title": "CodeQL: View AST"
@@ -1092,6 +1100,14 @@
"command": "codeQL.quickEvalContextEditor",
"when": "false"
},
{
"command": "codeQL.debug.quickEval",
"when": "config.codeQL.canary && editorLangId == ql"
},
{
"command": "codeQL.debug.quickEvalContextEditor",
"when": "false"
},
{
"command": "codeQL.openReferencedFile",
"when": "resourceExtname == .qlref"
@@ -1394,6 +1410,10 @@
"command": "codeQL.quickEvalContextEditor",
"when": "editorLangId == ql"
},
{
"command": "codeQL.debug.quickEvalContextEditor",
"when": "config.codeQL.canary && editorLangId == ql"
},
{
"command": "codeQL.openReferencedFileContextEditor",
"when": "resourceExtname == .qlref"

View File

@@ -96,6 +96,13 @@ export type LocalQueryCommands = {
"codeQL.quickEvalContextEditor": (uri: Uri) => Promise<void>;
"codeQL.codeLensQuickEval": (uri: Uri, range: Range) => Promise<void>;
"codeQL.quickQuery": () => Promise<void>;
"codeQL.getCurrentQuery": () => Promise<string>;
};
// Debugger commands
export type DebuggerCommands = {
"codeQL.debug.quickEval": (uri: Uri) => Promise<void>;
"codeQL.debug.quickEvalContextEditor": (uri: Uri) => Promise<void>;
};
export type ResultsViewCommands = {
@@ -269,6 +276,7 @@ export type AllExtensionCommands = BaseCommands &
ResultsViewCommands &
QueryHistoryCommands &
LocalDatabasesCommands &
DebuggerCommands &
VariantAnalysisCommands &
DatabasePanelCommands &
AstCfgCommands &

View File

@@ -6,24 +6,38 @@ import {
} from "vscode";
import { getOnDiskWorkspaceFolders, showAndLogErrorMessage } from "../helpers";
import { LocalQueries } from "../local-queries";
import { getQuickEvalContext, validateQueryPath } from "../run-queries-shared";
import * as CodeQLDebugProtocol from "./debug-protocol";
/**
* The CodeQL launch arguments, as specified in "launch.json".
*/
interface QLDebugArgs {
query: string;
database: string;
additionalPacks: string[] | string;
extensionPacks: string[] | string;
query?: string;
database?: string;
additionalPacks?: string[] | string;
extensionPacks?: string[] | string;
quickEval?: boolean;
noDebug?: boolean;
}
type QLDebugConfiguration = DebugConfiguration & Partial<QLDebugArgs>;
interface QLResolvedDebugArgs extends QLDebugArgs {
additionalPacks: string[];
extensionPacks: string[];
}
/**
* The debug configuration for a CodeQL configuration.
*
* This just combines `QLDebugArgs` with the standard debug configuration properties.
*/
type QLDebugConfiguration = DebugConfiguration & QLDebugArgs;
/**
* A CodeQL debug configuration after all variables and defaults have been resolved. This is what
* is passed to the debug adapter via the `launch` request.
*/
export type QLResolvedDebugConfiguration = DebugConfiguration &
QLResolvedDebugArgs;
CodeQLDebugProtocol.LaunchConfig;
/**
* Implementation of `DebugConfigurationProvider` for CodeQL.
*/
export class QLDebugConfigurationProvider
implements DebugConfigurationProvider
{
@@ -36,10 +50,12 @@ export class QLDebugConfigurationProvider
): DebugConfiguration {
const qlConfiguration = <QLDebugConfiguration>debugConfiguration;
// Fill in defaults
// Fill in defaults for properties whose default value is a command invocation. VS Code will
// invoke any commands to fill in actual values, then call
// `resolveDebugConfigurationWithSubstitutedVariables()`with the result.
const resultConfiguration: QLDebugConfiguration = {
...qlConfiguration,
query: qlConfiguration.query ?? "${file}",
query: qlConfiguration.query ?? "${command:currentQuery}",
database: qlConfiguration.database ?? "${command:currentDatabase}",
};
@@ -83,12 +99,23 @@ export class QLDebugConfigurationProvider
? [qlConfiguration.extensionPacks]
: qlConfiguration.extensionPacks;
const quickEval = qlConfiguration.quickEval ?? false;
validateQueryPath(qlConfiguration.query, quickEval);
const quickEvalContext = quickEval
? await getQuickEvalContext(undefined)
: undefined;
const resultConfiguration: QLResolvedDebugConfiguration = {
...qlConfiguration,
name: qlConfiguration.name,
request: qlConfiguration.request,
type: qlConfiguration.type,
query: qlConfiguration.query,
database: qlConfiguration.database,
additionalPacks,
extensionPacks,
quickEvalPosition: quickEvalContext?.quickEvalPosition,
noDebug: qlConfiguration.noDebug ?? false,
};
return resultConfiguration;

View File

@@ -17,6 +17,9 @@ export interface EvaluationStartedEventBody {
outputDir: string;
}
/**
* Custom event to provide additional information about a running evaluation.
*/
export interface EvaluationStartedEvent extends DebugProtocol.Event {
event: "codeql-evaluation-started";
body: EvaluationStartedEventBody;
@@ -28,6 +31,9 @@ export interface EvaluationCompletedEventBody {
evaluationTime: number;
}
/**
* Custom event to provide additional information about a completed evaluation.
*/
export interface EvaluationCompletedEvent extends DebugProtocol.Event {
event: "codeql-evaluation-completed";
body: EvaluationCompletedEventBody;
@@ -61,10 +67,28 @@ export type AnyResponse = InitializeResponse;
export type AnyProtocolMessage = AnyEvent | AnyRequest | AnyResponse;
export interface LaunchRequestArguments
extends DebugProtocol.LaunchRequestArguments {
query: string;
database: string;
additionalPacks: string[];
extensionPacks: string[];
export interface Position {
fileName: string;
line: number;
column: number;
endLine: number;
endColumn: number;
}
export interface LaunchConfig {
/** Full path to query (.ql) file. */
query: string;
/** Full path to the database directory. */
database: string;
/** Full paths to `--additional-packs` directories. */
additionalPacks: string[];
/** Pack names of extension packs. */
extensionPacks: string[];
/** Optional quick evaluation position. */
quickEvalPosition: Position | undefined;
/** Run the query without debugging it. */
noDebug: boolean;
}
export type LaunchRequestArguments = DebugProtocol.LaunchRequestArguments &
LaunchConfig;

View File

@@ -5,16 +5,20 @@ import {
LoggingDebugSession,
OutputEvent,
ProgressEndEvent,
StoppedEvent,
TerminatedEvent,
} from "@vscode/debugadapter";
import { DebugProtocol } from "@vscode/debugprotocol";
import { Disposable } from "vscode";
import { CancellationTokenSource } from "vscode-jsonrpc";
import { BaseLogger, LogOptions } from "../common";
import { BaseLogger, LogOptions, queryServerLogger } from "../common";
import { QueryResultType } from "../pure/new-messages";
import { CoreQueryResults, CoreQueryRun, QueryRunner } from "../queryRunner";
import * as CodeQLDebugProtocol from "./debug-protocol";
// More complete implementations of `Event` for certain events, because the classes from
// `@vscode/debugadapter` make it more difficult to provide some of the message values.
class ProgressStartEvent
extends Event
implements DebugProtocol.ProgressStartEvent
@@ -95,11 +99,42 @@ class EvaluationCompletedEvent
}
}
/**
* Possible states of the debug session. Used primarily to guard against unexpected requests.
*/
type State =
| "uninitialized"
| "initialized"
| "running"
| "stopped"
| "terminated";
// IDs for error messages generated by the debug adapter itself.
/** Received a DAP message while in an unexpected state. */
const ERROR_UNEXPECTED_STATE = 1;
/** ID of the "thread" that represents the query evaluation. */
const QUERY_THREAD_ID = 1;
/** The user-visible name of the query evaluation thread. */
const QUERY_THREAD_NAME = "Evaluation thread";
/**
* An in-process implementation of the debug adapter for CodeQL queries.
*
* For now, this is pretty much just a wrapper around the query server.
*/
export class QLDebugSession extends LoggingDebugSession implements Disposable {
private state: State = "uninitialized";
private terminateOnComplete = false;
private args: CodeQLDebugProtocol.LaunchRequestArguments | undefined =
undefined;
private tokenSource: CancellationTokenSource | undefined = undefined;
private queryRun: CoreQueryRun | undefined = undefined;
private lastResult:
| CodeQLDebugProtocol.EvaluationCompletedEventBody
| undefined = undefined;
constructor(
private readonly queryStorageDir: string,
@@ -113,22 +148,50 @@ export class QLDebugSession extends LoggingDebugSession implements Disposable {
}
protected dispatchRequest(request: DebugProtocol.Request): void {
// We just defer to the base class implementation, but having this override makes it easy to set
// a breakpoint that will be hit for any message received by the debug adapter.
void queryServerLogger.log(`DAP request: ${request.command}`);
super.dispatchRequest(request);
}
private unexpectedState(response: DebugProtocol.Response): void {
this.sendErrorResponse(
response,
ERROR_UNEXPECTED_STATE,
"CodeQL debug adapter received request '{_request}' while in unexpected state '{_actualState}'.",
{
_request: response.command,
_actualState: this.state,
},
);
}
protected initializeRequest(
response: DebugProtocol.InitializeResponse,
_args: DebugProtocol.InitializeRequestArguments,
): void {
response.body = response.body ?? {};
response.body.supportsStepBack = false;
response.body.supportsStepInTargetsRequest = false;
response.body.supportsRestartFrame = false;
response.body.supportsGotoTargetsRequest = false;
switch (this.state) {
case "uninitialized":
response.body = response.body ?? {};
response.body.supportsStepBack = false;
response.body.supportsStepInTargetsRequest = false;
response.body.supportsRestartFrame = false;
response.body.supportsGotoTargetsRequest = false;
response.body.supportsCancelRequest = true;
response.body.supportsTerminateRequest = true;
response.body.supportsModulesRequest = false;
response.body.supportsConfigurationDoneRequest = true;
response.body.supportsRestartRequest = false;
this.state = "initialized";
this.sendResponse(response);
this.sendResponse(response);
this.sendEvent(new InitializedEvent());
break;
this.sendEvent(new InitializedEvent());
default:
this.unexpectedState(response);
break;
}
}
protected configurationDoneRequest(
@@ -144,8 +207,32 @@ export class QLDebugSession extends LoggingDebugSession implements Disposable {
_args: DebugProtocol.DisconnectArguments,
_request?: DebugProtocol.Request,
): void {
response.body = response.body ?? {};
// Neither of the args (`terminateDebuggee` and `restart`) matter for CodeQL.
this.terminateOrDisconnect(response);
}
protected terminateRequest(
response: DebugProtocol.TerminateResponse,
_args: DebugProtocol.TerminateArguments,
_request?: DebugProtocol.Request,
): void {
this.terminateOrDisconnect(response);
}
private terminateOrDisconnect(response: DebugProtocol.Response): void {
switch (this.state) {
case "running":
this.terminateOnComplete = true;
this.cancelEvaluation();
break;
case "stopped":
this.terminateAndExit();
break;
default:
// Ignore
break;
}
this.sendResponse(response);
}
@@ -155,7 +242,47 @@ export class QLDebugSession extends LoggingDebugSession implements Disposable {
args: CodeQLDebugProtocol.LaunchRequestArguments,
_request?: DebugProtocol.Request,
): void {
void this.launch(response, args); //TODO: Cancelation?
switch (this.state) {
case "initialized":
this.args = args;
// If `noDebug` is set, then terminate after evaluation instead of stopping.
this.terminateOnComplete = this.args.noDebug === true;
response.body = response.body ?? {};
// Send the response immediately. We'll send a "stopped" message when the evaluation is complete.
this.sendResponse(response);
void this.evaluate();
break;
default:
this.unexpectedState(response);
break;
}
}
protected continueRequest(
response: DebugProtocol.ContinueResponse,
_args: DebugProtocol.ContinueArguments,
_request?: DebugProtocol.Request,
): void {
switch (this.state) {
case "stopped":
response.body = response.body ?? {};
response.body.allThreadsContinued = true;
// Send the response immediately. We'll send a "stopped" message when the evaluation is complete.
this.sendResponse(response);
void this.evaluate();
break;
default:
this.unexpectedState(response);
break;
}
}
protected cancelRequest(
@@ -163,10 +290,18 @@ export class QLDebugSession extends LoggingDebugSession implements Disposable {
args: DebugProtocol.CancelArguments,
_request?: DebugProtocol.Request,
): void {
if (args.progressId !== undefined) {
if (this.queryRun?.id === args.progressId) {
this.cancelEvaluation();
}
switch (this.state) {
case "running":
if (args.progressId !== undefined) {
if (this.queryRun!.id === args.progressId) {
this.cancelEvaluation();
}
}
break;
default:
// Ignore;
break;
}
this.sendResponse(response);
@@ -174,17 +309,17 @@ export class QLDebugSession extends LoggingDebugSession implements Disposable {
protected threadsRequest(
response: DebugProtocol.ThreadsResponse,
request?: DebugProtocol.Request,
_request?: DebugProtocol.Request,
): void {
response.body = response.body ?? {};
response.body.threads = [
{
id: 1,
name: "Evaluation thread",
id: QUERY_THREAD_ID,
name: QUERY_THREAD_NAME,
},
];
super.threadsRequest(response, request);
this.sendResponse(response);
}
protected stackTraceRequest(
@@ -193,22 +328,12 @@ export class QLDebugSession extends LoggingDebugSession implements Disposable {
_request?: DebugProtocol.Request,
): void {
response.body = response.body ?? {};
response.body.stackFrames = [];
response.body.stackFrames = []; // No frames for now.
super.stackTraceRequest(response, _args, _request);
}
private async launch(
response: DebugProtocol.LaunchResponse,
args: CodeQLDebugProtocol.LaunchRequestArguments,
): Promise<void> {
response.body = response.body ?? {};
this.args = args;
void this.evaluate(response);
}
/** Creates a `BaseLogger` that sends output to the debug console. */
private createLogger(): BaseLogger {
return {
log: async (message: string, _options: LogOptions): Promise<void> => {
@@ -217,21 +342,24 @@ export class QLDebugSession extends LoggingDebugSession implements Disposable {
};
}
private async evaluate(
response: DebugProtocol.LaunchResponse,
): Promise<void> {
// Send the response immediately. We'll send a "stopped" message when the evaluation is complete.
this.sendResponse(response);
/**
* Runs the query or quickeval, and notifies the debugger client when the evaluation completes.
*
* This function is invoked from the `launch` and `continue` handlers, without awaiting its
* result.
*/
private async evaluate(): Promise<void> {
const args = this.args!;
this.tokenSource = new CancellationTokenSource();
try {
// Create the query run, which will give us some information about the query even before the
// evaluation has completed.
this.queryRun = this.queryRunner.createQueryRun(
args.database,
{
queryPath: args.query,
quickEvalPosition: undefined,
quickEvalPosition: args.quickEvalPosition,
},
true,
args.additionalPacks,
@@ -241,6 +369,8 @@ export class QLDebugSession extends LoggingDebugSession implements Disposable {
undefined,
);
this.state = "running";
// Send the `EvaluationStarted` event first, to let the client known where the outputs are
// going to show up.
this.sendEvent(
@@ -249,6 +379,8 @@ export class QLDebugSession extends LoggingDebugSession implements Disposable {
this.queryRun.outputDir.querySaveDir,
),
);
// Report progress via the debugger protocol.
const progressStart = new ProgressStartEvent(
this.queryRun.id,
"Running query",
@@ -286,9 +418,14 @@ export class QLDebugSession extends LoggingDebugSession implements Disposable {
}
}
/**
* Mark the evaluation as completed, and notify the client of the result.
*/
private completeEvaluation(
result: CodeQLDebugProtocol.EvaluationCompletedEventBody,
): void {
this.lastResult = result;
// Report the end of the progress
this.sendEvent(new ProgressEndEvent(this.queryRun!.id));
// Report the evaluation result
@@ -300,13 +437,25 @@ export class QLDebugSession extends LoggingDebugSession implements Disposable {
this.sendEvent(outputEvent);
}
if (this.terminateOnComplete) {
this.terminateAndExit();
} else {
// Report the session as "stopped", but keep the session open.
this.sendEvent(new StoppedEvent("entry", QUERY_THREAD_ID));
this.state = "stopped";
}
this.queryRun = undefined;
}
private terminateAndExit(): void {
// Report the debugging session as terminated.
this.sendEvent(new TerminatedEvent());
// Report the debuggee as exited.
this.sendEvent(new ExitedEvent(result.resultType));
this.sendEvent(new ExitedEvent(this.lastResult!.resultType));
this.queryRun = undefined;
this.state = "terminated";
}
private disposeTokenSource(): void {

View File

@@ -4,19 +4,17 @@ import {
DebugAdapterDescriptorFactory,
DebugAdapterExecutable,
DebugAdapterInlineImplementation,
DebugAdapterServer,
DebugConfigurationProviderTriggerKind,
DebugSession,
ProviderResult,
} from "vscode";
import { isCanary } from "../config";
import { LocalQueries } from "../local-queries";
import { DisposableObject } from "../pure/disposable-object";
import { QueryRunner } from "../queryRunner";
import { QLDebugConfigurationProvider } from "./debug-configuration";
import { QLDebugSession } from "./debug-session";
const useInlineImplementation = true;
export class QLDebugAdapterDescriptorFactory
extends DisposableObject
implements DebugAdapterDescriptorFactory
@@ -27,6 +25,7 @@ export class QLDebugAdapterDescriptorFactory
localQueries: LocalQueries,
) {
super();
this.push(debug.registerDebugAdapterDescriptorFactory("codeql", this));
this.push(
debug.registerDebugConfigurationProvider(
@@ -43,13 +42,12 @@ export class QLDebugAdapterDescriptorFactory
_session: DebugSession,
_executable: DebugAdapterExecutable | undefined,
): ProviderResult<DebugAdapterDescriptor> {
if (useInlineImplementation) {
return new DebugAdapterInlineImplementation(
new QLDebugSession(this.queryStorageDir, this.queryRunner),
);
} else {
return new DebugAdapterServer(2112);
if (!isCanary()) {
throw new Error("The CodeQL debugger feature is not available yet.");
}
return new DebugAdapterInlineImplementation(
new QLDebugSession(this.queryStorageDir, this.queryRunner),
);
}
private handleOnDidStartDebugSession(session: DebugSession): void {

View File

@@ -6,7 +6,10 @@ import {
// window,
Uri,
CancellationTokenSource,
commands,
} from "vscode";
import { DebuggerCommands } from "../common/commands";
import { isCanary } from "../config";
import { ResultsView } from "../interface";
import { WebviewReveal } from "../interface-utils";
import { DatabaseManager } from "../local-databases";
@@ -18,11 +21,16 @@ import { QueryOutputDir } from "../run-queries-shared";
import { QLResolvedDebugConfiguration } from "./debug-configuration";
import * as CodeQLDebugProtocol from "./debug-protocol";
/**
* Listens to messages passing between VS Code and the debug adapter, so that we can supplement the
* UI.
*/
class QLDebugAdapterTracker
extends DisposableObject
implements DebugAdapterTracker
{
private readonly configuration: QLResolvedDebugConfiguration;
/** The `LocalQueryRun` of the current evaluation, if one is running. */
private localQueryRun: LocalQueryRun | undefined;
/** The promise of the most recently queued deferred message handler. */
private lastDeferredMessageHandler: Promise<void> = Promise.resolve();
@@ -66,11 +74,24 @@ class QLDebugAdapterTracker
this.dispose();
}
/**
* Queues a message handler to be executed once all other pending message handlers have completed.
*
* The `onDidSendMessage()` function is synchronous, so it needs to return before any async
* handling of the msssage is completed. We can't just launch the message handler directly from
* `onDidSendMessage()`, though, because if the message handler's implementation blocks awaiting
* a promise, then another event might be received by `onDidSendMessage()` while the first message
* handler is still incomplete.
*
* To enforce sequential execution of event handlers, we queue each new handler as a `finally()`
* handler for the most recently queued message.
*/
private queueMessageHandler(handler: () => Promise<void>): void {
this.lastDeferredMessageHandler =
this.lastDeferredMessageHandler.finally(handler);
}
/** Updates the UI to track the currently executing query. */
private async onEvaluationStarted(
body: CodeQLDebugProtocol.EvaluationStartedEventBody,
): Promise<void> {
@@ -83,11 +104,17 @@ class QLDebugAdapterTracker
debug.stopDebugging(this.session),
);
const quickEval =
this.configuration.quickEvalPosition !== undefined
? {
quickEvalPosition: this.configuration.quickEvalPosition,
quickEvalText: "quickeval!!!", // TODO: Have the debug adapter return the range, and extract the text from the editor.
}
: undefined;
this.localQueryRun = await this.localQueries.createLocalQueryRun(
{
queryPath: this.configuration.query,
quickEvalPosition: undefined,
quickEvalText: undefined,
quickEval,
},
dbItem,
new QueryOutputDir(body.outputDir),
@@ -95,6 +122,7 @@ class QLDebugAdapterTracker
);
}
/** Update the UI after a query has finished evaluating. */
private async onEvaluationCompleted(
body: CodeQLDebugProtocol.EvaluationCompletedEventBody,
): Promise<void> {
@@ -106,6 +134,7 @@ class QLDebugAdapterTracker
}
}
/** Service handling the UI for CodeQL debugging. */
export class DebuggerUI
extends DisposableObject
implements DebugAdapterTrackerFactory
@@ -119,7 +148,16 @@ export class DebuggerUI
) {
super();
this.push(debug.registerDebugAdapterTrackerFactory("codeql", this));
if (isCanary()) {
this.push(debug.registerDebugAdapterTrackerFactory("codeql", this));
}
}
public getCommands(): DebuggerCommands {
return {
"codeQL.debug.quickEval": this.quickEval.bind(this),
"codeQL.debug.quickEvalContextEditor": this.quickEval.bind(this),
};
}
public createDebugAdapterTracker(
@@ -143,6 +181,14 @@ export class DebuggerUI
this.sessions.delete(session.id);
}
private async quickEval(_uri: Uri): Promise<void> {
await commands.executeCommand("workbench.action.debug.start", {
config: {
quickEval: true,
},
});
}
private getTrackerForSession(
session: DebugSession,
): QLDebugAdapterTracker | undefined {

View File

@@ -863,16 +863,13 @@ async function activateWithInstalledDistribution(
ctx.subscriptions.push(localQueries);
void extLogger.log("Initializing debugger factory.");
const debuggerFactory = ctx.subscriptions.push(
ctx.subscriptions.push(
new QLDebugAdapterDescriptorFactory(queryStorageDir, qs, localQueries),
);
void debuggerFactory;
void extLogger.log("Initializing debugger UI.");
const debuggerUI = ctx.subscriptions.push(
new DebuggerUI(localQueryResultsView, localQueries, dbm),
);
void debuggerUI;
const debuggerUI = new DebuggerUI(localQueryResultsView, localQueries, dbm);
ctx.subscriptions.push(debuggerUI);
void extLogger.log("Initializing QLTest interface.");
const testExplorerExtension = extensions.getExtension<TestHub>(
@@ -940,6 +937,7 @@ async function activateWithInstalledDistribution(
...summaryLanguageSupport.getCommands(),
...testUiCommands,
...mockServer.getCommands(),
...debuggerUI.getCommands(),
};
for (const [commandName, command] of Object.entries(allCommands)) {

View File

@@ -13,6 +13,7 @@ import {
ThemeIcon,
ThemeColor,
workspace,
ProgressLocation,
} from "vscode";
import { pathExists, stat, readdir, remove } from "fs-extra";
@@ -21,7 +22,12 @@ import {
DatabaseItem,
DatabaseManager,
} from "./local-databases";
import { ProgressCallback, withProgress } from "./progress";
import {
ProgressCallback,
ProgressContext,
withInheritedProgress,
withProgress,
} from "./progress";
import {
isLikelyDatabaseRoot,
isLikelyDbLanguageFolder,
@@ -255,7 +261,7 @@ export class DatabaseUI extends DisposableObject {
token: CancellationToken,
): Promise<void> {
try {
await this.chooseAndSetDatabase(true, progress, token);
await this.chooseAndSetDatabase(true, { progress, token });
} catch (e) {
void showAndLogExceptionWithTelemetry(
redactableError(
@@ -416,7 +422,7 @@ export class DatabaseUI extends DisposableObject {
token: CancellationToken,
): Promise<void> {
try {
await this.chooseAndSetDatabase(false, progress, token);
await this.chooseAndSetDatabase(false, { progress, token });
} catch (e: unknown) {
void showAndLogExceptionWithTelemetry(
redactableError(
@@ -604,7 +610,8 @@ export class DatabaseUI extends DisposableObject {
}
private async handleGetCurrentDatabase(): Promise<string | undefined> {
return this.databaseManager.currentDatabaseItem?.databaseUri.fsPath;
const dbItem = await this.getDatabaseItemInternal(undefined);
return dbItem?.databaseUri.fsPath;
}
private async handleSetCurrentDatabase(uri: Uri): Promise<void> {
@@ -722,9 +729,24 @@ export class DatabaseUI extends DisposableObject {
public async getDatabaseItem(
progress: ProgressCallback,
token: CancellationToken,
): Promise<DatabaseItem | undefined> {
return await this.getDatabaseItemInternal({ progress, token });
}
/**
* Return the current database directory. If we don't already have a
* current database, ask the user for one, and return that, or
* undefined if they cancel.
*
* Unlike `getDatabaseItem()`, this function does not require the caller to pass in a progress
* context. If `progress` is `undefined`, then this command will create a new progress
* notification if it tries to perform any long-running operations.
*/
private async getDatabaseItemInternal(
progress: ProgressContext | undefined,
): Promise<DatabaseItem | undefined> {
if (this.databaseManager.currentDatabaseItem === undefined) {
await this.chooseAndSetDatabase(false, progress, token);
await this.chooseAndSetDatabase(false, progress);
}
return this.databaseManager.currentDatabaseItem;
@@ -754,31 +776,40 @@ export class DatabaseUI extends DisposableObject {
*/
private async chooseAndSetDatabase(
byFolder: boolean,
progress: ProgressCallback,
token: CancellationToken,
progress: ProgressContext | undefined,
): Promise<DatabaseItem | undefined> {
const uri = await chooseDatabaseDir(byFolder);
if (!uri) {
return undefined;
}
if (byFolder) {
const fixedUri = await this.fixDbUri(uri);
// we are selecting a database folder
return await this.setCurrentDatabase(progress, token, fixedUri);
} else {
// we are selecting a database archive. Must unzip into a workspace-controlled area
// before importing.
return await importArchiveDatabase(
this.app.commands,
uri.toString(true),
this.databaseManager,
this.storagePath,
progress,
token,
this.queryServer?.cliServer,
);
}
return await withInheritedProgress(
progress,
async (progress, token) => {
if (byFolder) {
const fixedUri = await this.fixDbUri(uri);
// we are selecting a database folder
return await this.setCurrentDatabase(progress, token, fixedUri);
} else {
// we are selecting a database archive. Must unzip into a workspace-controlled area
// before importing.
return await importArchiveDatabase(
this.app.commands,
uri.toString(true),
this.databaseManager,
this.storagePath,
progress,
token,
this.queryServer?.cliServer,
);
}
},
{
location: ProgressLocation.Notification,
cancellable: true,
title: "Opening database",
},
);
}
/**

View File

@@ -6,6 +6,7 @@ import {
Range,
Uri,
window,
workspace,
} from "vscode";
import { BaseLogger, extLogger, Logger, TeeLogger } from "./common";
import { MAX_QUERIES } from "./config";
@@ -33,14 +34,16 @@ import { ResultsView } from "./interface";
import { DatabaseItem, DatabaseManager } from "./local-databases";
import {
createInitialQueryInfo,
determineSelectedQuery,
EvaluatorLogPaths,
generateEvalLogSummaries,
getQuickEvalContext,
logEndSummary,
promptUserToSaveChanges,
QueryEvaluationInfo,
QueryOutputDir,
QueryWithResults,
SelectedQuery,
validateQueryUri,
} from "./run-queries-shared";
import { CompletedLocalQueryInfo, LocalQueryInfo } from "./query-results";
import { WebviewReveal } from "./interface-utils";
@@ -74,6 +77,25 @@ function formatResultMessage(result: CoreQueryResults): string {
}
}
/**
* If either the query file or the quickeval file is dirty, give the user the chance to save them.
*/
async function promptToSaveQueryIfNeeded(query: SelectedQuery): Promise<void> {
// There seems to be no way to ask VS Code to find an existing text document by name, without
// automatically opening the document if it is not found.
const queryUri = Uri.file(query.queryPath).toString();
const quickEvalUri =
query.quickEval !== undefined
? Uri.file(query.quickEval.quickEvalPosition.fileName).toString()
: undefined;
for (const openDocument of workspace.textDocuments) {
const documentUri = openDocument.uri.toString();
if (documentUri === queryUri || documentUri === quickEvalUri) {
await promptUserToSaveChanges(openDocument);
}
}
}
/**
* Tracks the evaluation of a local query, including its interactions with the UI.
*
@@ -237,6 +259,7 @@ export class LocalQueries extends DisposableObject {
"codeQL.quickEvalContextEditor": this.quickEval.bind(this),
"codeQL.codeLensQuickEval": this.codeLensQuickEval.bind(this),
"codeQL.quickQuery": this.quickQuery.bind(this),
"codeQL.getCurrentQuery": this.getCurrentQuery.bind(this),
};
}
@@ -375,6 +398,23 @@ export class LocalQueries extends DisposableObject {
);
}
/**
* Gets the current active query.
*
* For now, the "active query" is just whatever query is in the active text editor. Once we have a
* propery "queries" panel, we can provide a way to select the current query there.
*/
private async getCurrentQuery(): Promise<string> {
const editor = window.activeTextEditor;
if (editor === undefined) {
throw new Error(
"No query was selected. Please select a query and try again.",
);
}
return validateQueryUri(editor.document.uri, false);
}
/**
* Creates a new `LocalQueryRun` object to track a query evaluation. This creates a timestamp
* file in the query's output directory, creates a `LocalQueryInfo` object, and registers that
@@ -445,15 +485,24 @@ export class LocalQueries extends DisposableObject {
databaseItem: DatabaseItem | undefined,
range?: Range,
): Promise<CoreCompletedQuery> {
const selectedQuery = await determineSelectedQuery(
queryUri,
quickEval,
range,
);
let queryPath: string;
if (queryUri !== undefined) {
// The query URI is provided by the command, most likely because the command was run from an
// editor context menu. Use the provided URI, but make sure it's a valid query.
queryPath = validateQueryUri(queryUri, quickEval);
} else {
// Use the currently selected query.
queryPath = await this.getCurrentQuery();
}
const selectedQuery: SelectedQuery = {
queryPath,
quickEval: quickEval ? await getQuickEvalContext(range) : undefined,
};
// If no databaseItem is specified, use the database currently selected in the Databases UI
databaseItem =
databaseItem || (await this.databaseUI.getDatabaseItem(progress, token));
databaseItem ?? (await this.databaseUI.getDatabaseItem(progress, token));
if (databaseItem === undefined) {
throw new Error("Can't run query without a selected database");
}
@@ -461,11 +510,13 @@ export class LocalQueries extends DisposableObject {
const additionalPacks = getOnDiskWorkspaceFolders();
const extensionPacks = await this.getDefaultExtensionPacks(additionalPacks);
await promptToSaveQueryIfNeeded(selectedQuery);
const coreQueryRun = this.queryRunner.createQueryRun(
databaseItem.databaseUri.fsPath,
{
queryPath: selectedQuery.queryPath,
quickEvalPosition: selectedQuery.quickEvalPosition,
quickEvalPosition: selectedQuery.quickEval?.quickEvalPosition,
},
true,
additionalPacks,

View File

@@ -82,6 +82,29 @@ export function withProgress<R>(
);
}
export interface ProgressContext {
progress: ProgressCallback;
token: CancellationToken;
}
/**
* Like `withProgress()`, except that the caller is not required to provide a progress context. If
* the caller does provide one, any long-running operations performed by `task` will use the
* supplied progress context. Otherwise, this function wraps `task` in a new progress context with
* the supplied options.
*/
export function withInheritedProgress<R>(
parent: ProgressContext | undefined,
task: ProgressTask<R>,
options: ProgressOptions,
): Thenable<R> {
if (parent !== undefined) {
return task(parent.progress, parent.token);
} else {
return withProgress(task, options);
}
}
/**
* Displays a progress monitor that indicates how much progess has been made
* reading from a stream.

View File

@@ -389,55 +389,33 @@ export interface QueryWithResults {
}
/**
* Information about which query will be to be run. `quickEvalPosition` and `quickEvalText`
* is only filled in if the query is a quick query.
* Validates that the specified URI represents a QL query, and returns the file system path to that
* query.
*
* If `allowLibraryFiles` is set, ".qll" files will also be allowed as query files.
*/
export interface SelectedQuery {
queryPath: string;
quickEvalPosition?: messages.Position;
quickEvalText?: string;
}
/**
* Determines which QL file to run during an invocation of `Run Query` or `Quick Evaluation`, as follows:
* - If the command was called by clicking on a file, then use that file.
* - Otherwise, use the file open in the current editor.
* - In either case, prompt the user to save the file if it is open with unsaved changes.
* - For `Quick Evaluation`, ensure the selected file is also the one open in the editor,
* and use the selected region.
* @param selectedResourceUri The selected resource when the command was run.
* @param quickEval Whether the command being run is `Quick Evaluation`.
*/
export async function determineSelectedQuery(
selectedResourceUri: Uri | undefined,
quickEval: boolean,
range?: Range,
): Promise<SelectedQuery> {
const editor = window.activeTextEditor;
// Choose which QL file to use.
let queryUri: Uri;
if (selectedResourceUri) {
// A resource was passed to the command handler, so use it.
queryUri = selectedResourceUri;
} else {
// No resource was passed to the command handler, so obtain it from the active editor.
// This usually happens when the command is called from the Command Palette.
if (editor === undefined) {
throw new Error(
"No query was selected. Please select a query and try again.",
);
} else {
queryUri = editor.document.uri;
}
}
export function validateQueryUri(
queryUri: Uri,
allowLibraryFiles: boolean,
): string {
if (queryUri.scheme !== "file") {
throw new Error("Can only run queries that are on disk.");
}
const queryPath = queryUri.fsPath;
validateQueryPath(queryPath, allowLibraryFiles);
return queryPath;
}
if (quickEval) {
/**
* Validates that the specified path represents a QL query
*
* If `allowLibraryFiles` is set, ".qll" files will also be allowed as query files.
*/
export function validateQueryPath(
queryPath: string,
allowLibraryFiles: boolean,
): void {
if (allowLibraryFiles) {
if (!(queryPath.endsWith(".ql") || queryPath.endsWith(".qll"))) {
throw new Error(
'The selected resource is not a CodeQL file; It should have the extension ".ql" or ".qll".',
@@ -450,40 +428,52 @@ export async function determineSelectedQuery(
);
}
}
}
// Whether we chose the file from the active editor or from a context menu,
// if the same file is open with unsaved changes in the active editor,
// then prompt the user to save it first.
if (editor !== undefined && editor.document.uri.fsPath === queryPath) {
if (await promptUserToSaveChanges(editor.document)) {
await editor.document.save();
}
export interface QuickEvalContext {
quickEvalPosition: messages.Position;
quickEvalText: string;
}
/**
* Gets the selection to be used for quick evaluation.
*
* If `range` is specified, then that range will be used. Otherwise, the current selection will be
* used.
*/
export async function getQuickEvalContext(
range: Range | undefined,
): Promise<QuickEvalContext> {
const editor = window.activeTextEditor;
if (editor === undefined) {
throw new Error("Can't run quick evaluation without an active editor.");
}
// For Quick Evaluation, the selected position comes from the active editor, but it's possible
// that query itself was a different file. We need to validate the path of the file we're using
// for the QuickEval selection in case it was different.
validateQueryUri(editor.document.uri, true);
const quickEvalPosition = await getSelectedPosition(editor, range);
let quickEvalText: string;
if (!editor.selection?.isEmpty) {
quickEvalText = editor.document.getText(editor.selection);
} else {
// capture the entire line if the user didn't select anything
const line = editor.document.lineAt(editor.selection.active.line);
quickEvalText = line.text.trim();
}
let quickEvalPosition: messages.Position | undefined = undefined;
let quickEvalText: string | undefined = undefined;
if (quickEval) {
if (editor === undefined) {
throw new Error("Can't run quick evaluation without an active editor.");
}
if (editor.document.fileName !== queryPath) {
// For Quick Evaluation we expect these to be the same.
// Report an error if we end up in this (hopefully unlikely) situation.
throw new Error(
"The selected resource for quick evaluation should match the active editor.",
);
}
quickEvalPosition = await getSelectedPosition(editor, range);
if (!editor.selection?.isEmpty) {
quickEvalText = editor.document.getText(editor.selection);
} else {
// capture the entire line if the user didn't select anything
const line = editor.document.lineAt(editor.selection.active.line);
quickEvalText = line.text.trim();
}
}
return {
quickEvalPosition,
quickEvalText,
};
}
return { queryPath, quickEvalPosition, quickEvalText };
/**
* Information about which query will be to be run, optionally including a QuickEval selection.
*/
export interface SelectedQuery {
queryPath: string;
quickEval?: QuickEvalContext;
}
/** Gets the selected position within the given editor. */
@@ -512,7 +502,7 @@ async function getSelectedPosition(
* @returns true if we should save changes and false if we should continue without saving changes.
* @throws UserCancellationException if we should abort whatever operation triggered this prompt
*/
async function promptUserToSaveChanges(
export async function promptUserToSaveChanges(
document: TextDocument,
): Promise<boolean> {
if (document.isDirty) {
@@ -526,7 +516,9 @@ async function promptUserToSaveChanges(
isCloseAffordance: false,
};
const cancelItem = { title: "Cancel", isCloseAffordance: true };
const message = "Query file has unsaved changes. Save now?";
const message = `Query file '${basename(
document.uri.fsPath,
)}' has unsaved changes. Save now?`;
const chosenItem = await window.showInformationMessage(
message,
{ modal: true },
@@ -595,7 +587,7 @@ export async function createInitialQueryInfo(
selectedQuery: SelectedQuery,
databaseInfo: DatabaseInfo,
): Promise<InitialQueryInfo> {
const isQuickEval = selectedQuery.quickEvalPosition !== undefined;
const isQuickEval = selectedQuery.quickEval !== undefined;
return {
queryPath: selectedQuery.queryPath,
isQuickEval,
@@ -603,10 +595,10 @@ export async function createInitialQueryInfo(
databaseInfo,
id: `${basename(selectedQuery.queryPath)}-${nanoid()}`,
start: new Date(),
...(isQuickEval
...(selectedQuery.quickEval !== undefined
? {
queryText: selectedQuery.quickEvalText!, // if this query is quick eval, it must have quick eval text
quickEvalPosition: selectedQuery.quickEvalPosition,
queryText: selectedQuery.quickEval.quickEvalText,
quickEvalPosition: selectedQuery.quickEval.quickEvalPosition,
}
: {
queryText: await readFile(selectedQuery.queryPath, "utf8"),