Merge pull request #2291 from github/dbartol/debug-adapter

Implement basic CodeQL debug adapter
This commit is contained in:
Dave Bartolomeo
2023-04-17 21:33:45 -04:00
committed by GitHub
27 changed files with 2463 additions and 254 deletions

View File

@@ -13,6 +13,8 @@
"@octokit/plugin-retry": "^3.0.9",
"@octokit/rest": "^19.0.4",
"@vscode/codicons": "^0.0.31",
"@vscode/debugadapter": "^1.59.0",
"@vscode/debugprotocol": "^1.59.0",
"@vscode/webview-ui-toolkit": "^1.0.1",
"ajv": "^8.11.0",
"child-process-promise": "^2.2.1",
@@ -19831,6 +19833,22 @@
"resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.31.tgz",
"integrity": "sha512-fldpXy7pHsQAMlU1pnGI23ypQ6xLk5u6SiABMFoAmlj4f2MR0iwg7C19IB1xvAEGG+dkxOfRSrbKF8ry7QqGQA=="
},
"node_modules/@vscode/debugadapter": {
"version": "1.59.0",
"resolved": "https://registry.npmjs.org/@vscode/debugadapter/-/debugadapter-1.59.0.tgz",
"integrity": "sha512-KfrQ/9QhTxBumxkqIWs9rsFLScdBIqEXx5pGbTXP7V9I3IIcwgdi5N55FbMxQY9tq6xK3KfJHAZLIXDwO7YfVg==",
"dependencies": {
"@vscode/debugprotocol": "1.59.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@vscode/debugprotocol": {
"version": "1.59.0",
"resolved": "https://registry.npmjs.org/@vscode/debugprotocol/-/debugprotocol-1.59.0.tgz",
"integrity": "sha512-Ks8NiZrCvybf9ebGLP8OUZQbEMIJYC8X0Ds54Q/szpT/SYEDjTksPvZlcWGTo7B9t5abjvbd0jkNH3blYaSuVw=="
},
"node_modules/@vscode/test-electron": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.2.0.tgz",
@@ -62845,6 +62863,19 @@
"resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.31.tgz",
"integrity": "sha512-fldpXy7pHsQAMlU1pnGI23ypQ6xLk5u6SiABMFoAmlj4f2MR0iwg7C19IB1xvAEGG+dkxOfRSrbKF8ry7QqGQA=="
},
"@vscode/debugadapter": {
"version": "1.59.0",
"resolved": "https://registry.npmjs.org/@vscode/debugadapter/-/debugadapter-1.59.0.tgz",
"integrity": "sha512-KfrQ/9QhTxBumxkqIWs9rsFLScdBIqEXx5pGbTXP7V9I3IIcwgdi5N55FbMxQY9tq6xK3KfJHAZLIXDwO7YfVg==",
"requires": {
"@vscode/debugprotocol": "1.59.0"
}
},
"@vscode/debugprotocol": {
"version": "1.59.0",
"resolved": "https://registry.npmjs.org/@vscode/debugprotocol/-/debugprotocol-1.59.0.tgz",
"integrity": "sha512-Ks8NiZrCvybf9ebGLP8OUZQbEMIJYC8X0Ds54Q/szpT/SYEDjTksPvZlcWGTo7B9t5abjvbd0jkNH3blYaSuVw=="
},
"@vscode/test-electron": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.2.0.tgz",

View File

@@ -76,6 +76,48 @@
"editor.wordBasedSuggestions": false
}
},
"debuggers": [
{
"type": "codeql",
"label": "CodeQL Debugger",
"languages": [
"ql"
],
"configurationAttributes": {
"launch": {
"properties": {
"query": {
"type": "string",
"description": "Path to query file (.ql)",
"default": "${file}"
},
"database": {
"type": "string",
"description": "Path to the target database"
},
"additionalPacks": {
"type": [
"array",
"string"
],
"description": "Additional folders to search for library packs. Defaults to searching all workspace folders."
},
"extensionPacks": {
"type": [
"array",
"string"
],
"description": "Names of extension packs to include in the evaluation. These are resolved from the locations specified in `additionalPacks`."
}
}
}
},
"variables": {
"currentDatabase": "codeQL.getCurrentDatabase",
"currentQuery": "codeQL.getCurrentQuery"
}
}
],
"jsonValidation": [
{
"fileMatch": "GitHub.vscode-codeql/databases.json",
@@ -314,6 +356,30 @@
"command": "codeQL.runQueryContextEditor",
"title": "CodeQL: Run Query on Selected Database"
},
{
"command": "codeQL.debugQuery",
"title": "CodeQL: Debug Query"
},
{
"command": "codeQL.debugQueryContextEditor",
"title": "CodeQL: Debug Query"
},
{
"command": "codeQL.startDebuggingSelection",
"title": "CodeQL: Debug Selection"
},
{
"command": "codeQL.startDebuggingSelectionContextEditor",
"title": "CodeQL: Debug Selection"
},
{
"command": "codeQL.continueDebuggingSelection",
"title": "CodeQL: Debug Selection"
},
{
"command": "codeQL.continueDebuggingSelectionContextEditor",
"title": "CodeQL: Debug Selection"
},
{
"command": "codeQL.runQueryOnMultipleDatabases",
"title": "CodeQL: Run Query on Multiple Databases"
@@ -453,6 +519,14 @@
"command": "codeQL.setCurrentDatabase",
"title": "CodeQL: Set Current Database"
},
{
"command": "codeQL.getCurrentDatabase",
"title": "CodeQL: Get Current Database"
},
{
"command": "codeQL.getCurrentQuery",
"title": "CodeQL: Get Current Query"
},
{
"command": "codeQL.viewAst",
"title": "CodeQL: View AST"
@@ -1038,6 +1112,30 @@
"command": "codeQL.runQueryContextEditor",
"when": "false"
},
{
"command": "codeQL.debugQuery",
"when": "config.codeQL.canary && editorLangId == ql && resourceExtname == .ql && !inDebugMode"
},
{
"command": "codeQL.debugQueryContextEditor",
"when": "false"
},
{
"command": "codeQL.startDebuggingSelection",
"when": "config.codeQL.canary && editorLangId == ql && debugState == inactive && debugConfigurationType == codeql"
},
{
"command": "codeQL.startDebuggingSelectionContextEditor",
"when": "false"
},
{
"command": "codeQL.continueDebuggingSelection",
"when": "config.codeQL.canary && editorLangId == ql && debugState == stopped && debugType == codeql"
},
{
"command": "codeQL.continueDebuggingSelectionContextEditor",
"when": "false"
},
{
"command": "codeQL.runQueryOnMultipleDatabases",
"when": "resourceLangId == ql && resourceExtname == .ql"
@@ -1086,6 +1184,14 @@
"command": "codeQL.setCurrentDatabase",
"when": "false"
},
{
"command": "codeQL.getCurrentDatabase",
"when": "false"
},
{
"command": "codeQL.getCurrentQuery",
"when": "false"
},
{
"command": "codeQL.viewAst",
"when": "resourceScheme == codeql-zip-archive"
@@ -1350,7 +1456,7 @@
"editor/context": [
{
"command": "codeQL.runQueryContextEditor",
"when": "editorLangId == ql && resourceExtname == .ql"
"when": "editorLangId == ql && resourceExtname == .ql && !inDebugMode"
},
{
"command": "codeQL.runQueryOnMultipleDatabasesContextEditor",
@@ -1370,7 +1476,19 @@
},
{
"command": "codeQL.quickEvalContextEditor",
"when": "editorLangId == ql"
"when": "editorLangId == ql && debugState == inactive"
},
{
"command": "codeQL.debugQueryContextEditor",
"when": "config.codeQL.canary && editorLangId == ql && resourceExtname == .ql && !inDebugMode"
},
{
"command": "codeQL.startDebuggingSelectionContextEditor",
"when": "config.codeQL.canary && editorLangId == ql && debugState == inactive && debugConfigurationType == codeql"
},
{
"command": "codeQL.continueDebuggingSelectionContextEditor",
"when": "config.codeQL.canary && editorLangId == ql && debugState == stopped && debugType == codeql"
},
{
"command": "codeQL.openReferencedFileContextEditor",
@@ -1471,6 +1589,8 @@
"@octokit/plugin-retry": "^3.0.9",
"@octokit/rest": "^19.0.4",
"@vscode/codicons": "^0.0.31",
"@vscode/debugadapter": "^1.59.0",
"@vscode/debugprotocol": "^1.59.0",
"@vscode/webview-ui-toolkit": "^1.0.1",
"ajv": "^8.11.0",
"child-process-promise": "^2.2.1",

View File

@@ -11,6 +11,7 @@ import type {
VariantAnalysisScannedRepository,
VariantAnalysisScannedRepositoryResult,
} from "../variant-analysis/shared/variant-analysis";
import type { QLDebugConfiguration } from "../debugger/debug-configuration";
// A command function matching the signature that VS Code calls when
// a command is invoked from the title bar of a TreeView with
@@ -87,6 +88,15 @@ export type BuiltInVsCodeCommands = {
) => Promise<void>;
"vscode.open": (uri: Uri) => Promise<void>;
"vscode.openFolder": (uri: Uri) => Promise<void>;
// We type the `config` property specifically as a CodeQL debug configuration, since that's the
// only kinds we specify anyway.
"workbench.action.debug.start": (options?: {
config?: Partial<QLDebugConfiguration>;
noDebug?: boolean;
}) => Promise<void>;
"workbench.action.debug.stepInto": () => Promise<void>;
"workbench.action.debug.stepOver": () => Promise<void>;
"workbench.action.debug.stepOut": () => Promise<void>;
};
// Commands that are available before the extension is fully activated.
@@ -134,9 +144,20 @@ 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>;
"codeQL.createQuery": () => Promise<void>;
};
// Debugger commands
export type DebuggerCommands = {
"codeQL.debugQuery": (uri: Uri | undefined) => Promise<void>;
"codeQL.debugQueryContextEditor": (uri: Uri) => Promise<void>;
"codeQL.startDebuggingSelection": () => Promise<void>;
"codeQL.startDebuggingSelectionContextEditor": () => Promise<void>;
"codeQL.continueDebuggingSelection": () => Promise<void>;
"codeQL.continueDebuggingSelectionContextEditor": () => Promise<void>;
};
export type ResultsViewCommands = {
"codeQLQueryResults.up": () => Promise<void>;
"codeQLQueryResults.down": () => Promise<void>;
@@ -219,6 +240,7 @@ export type LocalDatabasesCommands = {
// Internal commands
"codeQLDatabases.removeOrphanedDatabases": () => Promise<void>;
"codeQL.getCurrentDatabase": () => Promise<string | undefined>;
};
// Commands tied to variant analysis
@@ -317,6 +339,7 @@ export type AllExtensionCommands = BaseCommands &
ResultsViewCommands &
QueryHistoryCommands &
LocalDatabasesCommands &
DebuggerCommands &
VariantAnalysisCommands &
DatabasePanelCommands &
AstCfgCommands &

View File

@@ -0,0 +1,132 @@
import {
CancellationToken,
DebugConfiguration,
DebugConfigurationProvider,
WorkspaceFolder,
} from "vscode";
import { getOnDiskWorkspaceFolders, showAndLogErrorMessage } from "../helpers";
import { LocalQueries } from "../local-queries";
import { getQuickEvalContext, validateQueryPath } from "../run-queries-shared";
import * as CodeQLProtocol from "./debug-protocol";
import { getErrorMessage } from "../pure/helpers-pure";
/**
* The CodeQL launch arguments, as specified in "launch.json".
*/
export interface QLDebugArgs {
query?: string;
database?: string;
additionalPacks?: string[] | string;
extensionPacks?: string[] | string;
quickEval?: boolean;
noDebug?: boolean;
}
/**
* The debug configuration for a CodeQL configuration.
*
* This just combines `QLDebugArgs` with the standard debug configuration properties.
*/
export 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 &
CodeQLProtocol.LaunchConfig;
/** If the specified value is a single element, then turn it into an array containing that element. */
function makeArray<T extends Exclude<any, any[]>>(value: T | T[]): T[] {
if (Array.isArray(value)) {
return value;
} else {
return [value];
}
}
/**
* Implementation of `DebugConfigurationProvider` for CodeQL.
*/
export class QLDebugConfigurationProvider
implements DebugConfigurationProvider
{
public constructor(private readonly localQueries: LocalQueries) {}
public resolveDebugConfiguration(
_folder: WorkspaceFolder | undefined,
debugConfiguration: DebugConfiguration,
_token?: CancellationToken,
): DebugConfiguration {
const qlConfiguration = <QLDebugConfiguration>debugConfiguration;
// 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 ?? "${command:currentQuery}",
database: qlConfiguration.database ?? "${command:currentDatabase}",
};
return resultConfiguration;
}
public async resolveDebugConfigurationWithSubstitutedVariables(
_folder: WorkspaceFolder | undefined,
debugConfiguration: DebugConfiguration,
_token?: CancellationToken,
): Promise<DebugConfiguration | null> {
try {
const qlConfiguration = debugConfiguration as QLDebugConfiguration;
if (qlConfiguration.query === undefined) {
throw new Error("No query was specified in the debug configuration.");
}
if (qlConfiguration.database === undefined) {
throw new Error(
"No database was specified in the debug configuration.",
);
}
// Fill in defaults here, instead of in `resolveDebugConfiguration`, to avoid the highly
// unusual case where one of the computed default values looks like a variable substitution.
const additionalPacks = makeArray(
qlConfiguration.additionalPacks ?? getOnDiskWorkspaceFolders(),
);
// Default to computing the extension packs based on the extension configuration and the search
// path.
const extensionPacks = makeArray(
qlConfiguration.extensionPacks ??
(await this.localQueries.getDefaultExtensionPacks(additionalPacks)),
);
const quickEval = qlConfiguration.quickEval ?? false;
validateQueryPath(qlConfiguration.query, quickEval);
const quickEvalContext = quickEval
? await getQuickEvalContext(undefined)
: undefined;
const resultConfiguration: QLResolvedDebugConfiguration = {
name: qlConfiguration.name,
request: qlConfiguration.request,
type: qlConfiguration.type,
query: qlConfiguration.query,
database: qlConfiguration.database,
additionalPacks,
extensionPacks,
quickEvalContext,
noDebug: qlConfiguration.noDebug ?? false,
};
return resultConfiguration;
} catch (e) {
// Any unhandled exception will result in an OS-native error message box, which seems ugly.
// We'll just show a real VS Code error message, then return null to prevent the debug session
// from starting.
void showAndLogErrorMessage(getErrorMessage(e));
return null;
}
}
}

View File

@@ -0,0 +1,102 @@
import { DebugProtocol } from "@vscode/debugprotocol";
import { QueryResultType } from "../pure/new-messages";
import { QuickEvalContext } from "../run-queries-shared";
// Events
export type Event = { type: "event" };
export type StoppedEvent = DebugProtocol.StoppedEvent &
Event & { event: "stopped" };
export type InitializedEvent = DebugProtocol.InitializedEvent &
Event & { event: "initialized" };
export type ExitedEvent = DebugProtocol.ExitedEvent &
Event & { event: "exited" };
export type OutputEvent = DebugProtocol.OutputEvent &
Event & { event: "output" };
/**
* Custom event to provide additional information about a running evaluation.
*/
export interface EvaluationStartedEvent extends Event {
event: "codeql-evaluation-started";
body: {
id: string;
outputDir: string;
quickEvalContext: QuickEvalContext | undefined;
};
}
/**
* Custom event to provide additional information about a completed evaluation.
*/
export interface EvaluationCompletedEvent extends Event {
event: "codeql-evaluation-completed";
body: {
resultType: QueryResultType;
message: string | undefined;
evaluationTime: number;
};
}
export type AnyEvent =
| StoppedEvent
| ExitedEvent
| InitializedEvent
| OutputEvent
| EvaluationStartedEvent
| EvaluationCompletedEvent;
// Requests
export type Request = DebugProtocol.Request & { type: "request" };
export type InitializeRequest = DebugProtocol.InitializeRequest &
Request & { command: "initialize" };
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 context. */
quickEvalContext: QuickEvalContext | undefined;
/** Run the query without debugging it. */
noDebug: boolean;
}
export interface LaunchRequest extends Request, DebugProtocol.LaunchRequest {
type: "request";
command: "launch";
arguments: DebugProtocol.LaunchRequestArguments & LaunchConfig;
}
export interface QuickEvalRequest extends Request {
command: "codeql-quickeval";
arguments: {
quickEvalContext: QuickEvalContext;
};
}
export type AnyRequest = InitializeRequest | LaunchRequest | QuickEvalRequest;
// Responses
export type Response = DebugProtocol.Response & { type: "response" };
export type InitializeResponse = DebugProtocol.InitializeResponse &
Response & { command: "initialize" };
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface QuickEvalResponse extends Response {}
export type AnyResponse = InitializeResponse | QuickEvalResponse;
export type AnyProtocolMessage = AnyEvent | AnyRequest | AnyResponse;

View File

@@ -0,0 +1,617 @@
import {
ContinuedEvent,
Event,
ExitedEvent,
InitializedEvent,
LoggingDebugSession,
OutputEvent,
ProgressEndEvent,
StoppedEvent,
TerminatedEvent,
} from "@vscode/debugadapter";
import { DebugProtocol as Protocol } from "@vscode/debugprotocol";
import { Disposable } from "vscode";
import { CancellationTokenSource } from "vscode-jsonrpc";
import { BaseLogger, LogOptions, queryServerLogger } from "../common";
import { QueryResultType } from "../pure/new-messages";
import { CoreQueryResults, CoreQueryRun, QueryRunner } from "../queryRunner";
import * as CodeQLProtocol from "./debug-protocol";
import { QuickEvalContext } from "../run-queries-shared";
import { getErrorMessage } from "../pure/helpers-pure";
import { DisposableObject } from "../pure/disposable-object";
// 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 Protocol.ProgressStartEvent {
public readonly event = "progressStart";
public readonly body: {
progressId: string;
title: string;
requestId?: number;
cancellable?: boolean;
message?: string;
percentage?: number;
};
constructor(
progressId: string,
title: string,
message?: string,
percentage?: number,
) {
super("progressStart");
this.body = {
progressId,
title,
message,
percentage,
};
}
}
class ProgressUpdateEvent
extends Event
implements Protocol.ProgressUpdateEvent
{
public readonly event = "progressUpdate";
public readonly body: {
progressId: string;
message?: string;
percentage?: number;
};
constructor(progressId: string, message?: string, percentage?: number) {
super("progressUpdate");
this.body = {
progressId,
message,
percentage,
};
}
}
class EvaluationStartedEvent
extends Event
implements CodeQLProtocol.EvaluationStartedEvent
{
public readonly type = "event";
public readonly event = "codeql-evaluation-started";
public readonly body: CodeQLProtocol.EvaluationStartedEvent["body"];
constructor(
id: string,
outputDir: string,
quickEvalContext: QuickEvalContext | undefined,
) {
super("codeql-evaluation-started");
this.body = {
id,
outputDir,
quickEvalContext,
};
}
}
class EvaluationCompletedEvent
extends Event
implements CodeQLProtocol.EvaluationCompletedEvent
{
public readonly type = "event";
public readonly event = "codeql-evaluation-completed";
public readonly body: CodeQLProtocol.EvaluationCompletedEvent["body"];
constructor(results: CoreQueryResults) {
super("codeql-evaluation-completed");
this.body = results;
}
}
/**
* 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 active query evaluation within a debug session.
*
* This class encapsulates the state and resources associated with the running query, to avoid
* having multiple properties within `QLDebugSession` that are only defined during query evaluation.
*/
class RunningQuery extends DisposableObject {
private readonly tokenSource = this.push(new CancellationTokenSource());
public readonly queryRun: CoreQueryRun;
public constructor(
queryRunner: QueryRunner,
config: CodeQLProtocol.LaunchConfig,
private readonly quickEvalContext: QuickEvalContext | undefined,
queryStorageDir: string,
private readonly logger: BaseLogger,
private readonly sendEvent: (event: Event) => void,
) {
super();
// Create the query run, which will give us some information about the query even before the
// evaluation has completed.
this.queryRun = queryRunner.createQueryRun(
config.database,
{
queryPath: config.query,
quickEvalPosition: quickEvalContext?.quickEvalPosition,
},
true,
config.additionalPacks,
config.extensionPacks,
queryStorageDir,
undefined,
undefined,
);
}
public get id(): string {
return this.queryRun.id;
}
/**
* Evaluates the query, firing progress events along the way. The evaluation can be cancelled by
* calling `cancel()`.
*
* This function does not throw exceptions to report query evaluation failure. It just returns an
* evaluation result with a failure message instead.
*/
public async evaluate(): Promise<
CodeQLProtocol.EvaluationCompletedEvent["body"]
> {
// Send the `EvaluationStarted` event first, to let the client known where the outputs are
// going to show up.
this.sendEvent(
new EvaluationStartedEvent(
this.queryRun.id,
this.queryRun.outputDir.querySaveDir,
this.quickEvalContext,
),
);
try {
// Report progress via the debugger protocol.
const progressStart = new ProgressStartEvent(
this.queryRun.id,
"Running query",
undefined,
0,
);
progressStart.body.cancellable = true;
this.sendEvent(progressStart);
try {
return await this.queryRun.evaluate(
(p) => {
const progressUpdate = new ProgressUpdateEvent(
this.queryRun.id,
p.message,
(p.step * 100) / p.maxStep,
);
this.sendEvent(progressUpdate);
},
this.tokenSource.token,
this.logger,
);
} finally {
this.sendEvent(new ProgressEndEvent(this.queryRun.id));
}
} catch (e) {
const message = getErrorMessage(e);
return {
resultType: QueryResultType.OTHER_ERROR,
message,
evaluationTime: 0,
};
}
}
/**
* Attempts to cancel the running evaluation.
*/
public cancel(): void {
this.tokenSource.cancel();
}
}
/**
* 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 {
/** A `BaseLogger` that sends output to the debug console. */
private readonly logger: BaseLogger = {
log: async (message: string, _options: LogOptions): Promise<void> => {
// Only send the output event if we're still connected to the query evaluation.
if (this.runningQuery !== undefined) {
this.sendEvent(new OutputEvent(message, "console"));
}
},
};
private state: State = "uninitialized";
private terminateOnComplete = false;
private args: CodeQLProtocol.LaunchRequest["arguments"] | undefined =
undefined;
private runningQuery: RunningQuery | undefined = undefined;
private lastResultType: QueryResultType = QueryResultType.CANCELLATION;
constructor(
private readonly queryStorageDir: string,
private readonly queryRunner: QueryRunner,
) {
super();
}
public dispose(): void {
if (this.runningQuery !== undefined) {
this.runningQuery.cancel();
}
}
protected dispatchRequest(request: Protocol.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: Protocol.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: Protocol.InitializeResponse,
_args: Protocol.InitializeRequestArguments,
): void {
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.sendEvent(new InitializedEvent());
break;
default:
this.unexpectedState(response);
break;
}
}
protected disconnectRequest(
response: Protocol.DisconnectResponse,
_args: Protocol.DisconnectArguments,
_request?: Protocol.Request,
): void {
// The client is forcing a disconnect. We'll signal cancellation, but since this request means
// that the debug session itself is about to go away, we'll stop processing events from the
// evaluation to avoid sending them to the client that is no longer interested in them.
this.terminateOrDisconnect(response, true);
}
protected terminateRequest(
response: Protocol.TerminateResponse,
_args: Protocol.TerminateArguments,
_request?: Protocol.Request,
): void {
// The client is requesting a graceful termination. This will signal the cancellation token of
// any in-progress evaluation, but that evaluation will continue to report events (like
// progress) until the cancellation takes effect.
this.terminateOrDisconnect(response, false);
}
private terminateOrDisconnect(
response: Protocol.Response,
force: boolean,
): void {
const runningQuery = this.runningQuery;
if (force) {
// Disconnect from the running query so that we stop processing its progress events.
this.runningQuery = undefined;
}
if (runningQuery !== undefined) {
this.terminateOnComplete = true;
runningQuery.cancel();
} else if (this.state === "stopped") {
this.terminateAndExit();
}
this.sendResponse(response);
}
protected launchRequest(
response: Protocol.LaunchResponse,
args: CodeQLProtocol.LaunchRequest["arguments"],
_request?: Protocol.Request,
): void {
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(this.args.quickEvalContext);
break;
default:
this.unexpectedState(response);
break;
}
}
protected nextRequest(
response: Protocol.NextResponse,
_args: Protocol.NextArguments,
_request?: Protocol.Request,
): void {
this.stepRequest(response);
}
protected stepInRequest(
response: Protocol.StepInResponse,
_args: Protocol.StepInArguments,
_request?: Protocol.Request,
): void {
this.stepRequest(response);
}
protected stepOutRequest(
response: Protocol.Response,
_args: Protocol.StepOutArguments,
_request?: Protocol.Request,
): void {
this.stepRequest(response);
}
protected stepBackRequest(
response: Protocol.StepBackResponse,
_args: Protocol.StepBackArguments,
_request?: Protocol.Request,
): void {
this.stepRequest(response);
}
private stepRequest(response: Protocol.Response): void {
switch (this.state) {
case "stopped":
this.sendResponse(response);
// We don't do anything with stepping yet, so just announce that we've stopped without
// actually doing anything.
// We don't even send the `EvaluationCompletedEvent`.
this.reportStopped();
break;
default:
this.unexpectedState(response);
break;
}
}
protected continueRequest(
response: Protocol.ContinueResponse,
_args: Protocol.ContinueArguments,
_request?: Protocol.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(undefined);
break;
default:
this.unexpectedState(response);
break;
}
}
protected cancelRequest(
response: Protocol.CancelResponse,
args: Protocol.CancelArguments,
_request?: Protocol.Request,
): void {
if (
args.progressId !== undefined &&
this.runningQuery?.id === args.progressId
) {
this.runningQuery.cancel();
}
this.sendResponse(response);
}
protected threadsRequest(
response: Protocol.ThreadsResponse,
_request?: Protocol.Request,
): void {
response.body = response.body ?? {};
response.body.threads = [
{
id: QUERY_THREAD_ID,
name: QUERY_THREAD_NAME,
},
];
this.sendResponse(response);
}
protected stackTraceRequest(
response: Protocol.StackTraceResponse,
_args: Protocol.StackTraceArguments,
_request?: Protocol.Request,
): void {
response.body = response.body ?? {};
response.body.stackFrames = []; // No frames for now.
super.stackTraceRequest(response, _args, _request);
}
protected customRequest(
command: string,
response: CodeQLProtocol.Response,
args: any,
request?: Protocol.Request,
): void {
switch (command) {
case "codeql-quickeval": {
this.quickEvalRequest(
response,
args as CodeQLProtocol.QuickEvalRequest["arguments"],
);
break;
}
default:
super.customRequest(command, response, args, request);
break;
}
}
protected quickEvalRequest(
response: CodeQLProtocol.QuickEvalResponse,
args: CodeQLProtocol.QuickEvalRequest["arguments"],
): void {
switch (this.state) {
case "stopped":
// Send the response immediately. We'll send a "stopped" message when the evaluation is complete.
this.sendResponse(response);
// For built-in requests that are expected to cause execution (`launch`, `continue`, `step`, etc.),
// the adapter does not send a `continued` event because the client already knows that's what
// is supposed to happen. For a custom request, though, we have to notify the client.
this.sendEvent(new ContinuedEvent(QUERY_THREAD_ID, true));
void this.evaluate(args.quickEvalContext);
break;
default:
this.unexpectedState(response);
break;
}
}
/**
* 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(
quickEvalContext: QuickEvalContext | undefined,
): Promise<void> {
const args = this.args!;
const runningQuery = new RunningQuery(
this.queryRunner,
args,
quickEvalContext,
this.queryStorageDir,
this.logger,
(event) => {
// If `this.runningQuery` is undefined, it means that we've already disconnected from this
// evaluation, and do not want any further events.
if (this.runningQuery !== undefined) {
this.sendEvent(event);
}
},
);
this.runningQuery = runningQuery;
this.state = "running";
try {
const result = await runningQuery.evaluate();
this.completeEvaluation(result);
} finally {
this.runningQuery = undefined;
runningQuery.dispose();
}
}
/**
* Mark the evaluation as completed, and notify the client of the result.
*/
private completeEvaluation(
result: CodeQLProtocol.EvaluationCompletedEvent["body"],
): void {
this.lastResultType = result.resultType;
// Report the evaluation result
this.sendEvent(new EvaluationCompletedEvent(result));
if (result.resultType !== QueryResultType.SUCCESS) {
// Report the result message as "important" output
const message = result.message ?? "Unknown error";
const outputEvent = new OutputEvent(message, "console");
this.sendEvent(outputEvent);
}
this.reportStopped();
}
private reportStopped(): void {
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";
}
}
private terminateAndExit(): void {
// Report the debugging session as terminated.
this.sendEvent(new TerminatedEvent());
// Report the debuggee as exited.
this.sendEvent(new ExitedEvent(this.lastResultType));
this.state = "terminated";
}
}

View File

@@ -0,0 +1,50 @@
import {
debug,
DebugAdapterDescriptor,
DebugAdapterDescriptorFactory,
DebugAdapterExecutable,
DebugAdapterInlineImplementation,
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";
export class QLDebugAdapterDescriptorFactory
extends DisposableObject
implements DebugAdapterDescriptorFactory
{
constructor(
private readonly queryStorageDir: string,
private readonly queryRunner: QueryRunner,
localQueries: LocalQueries,
) {
super();
this.push(debug.registerDebugAdapterDescriptorFactory("codeql", this));
this.push(
debug.registerDebugConfigurationProvider(
"codeql",
new QLDebugConfigurationProvider(localQueries),
DebugConfigurationProviderTriggerKind.Dynamic,
),
);
}
public createDebugAdapterDescriptor(
_session: DebugSession,
_executable: DebugAdapterExecutable | undefined,
): ProviderResult<DebugAdapterDescriptor> {
if (!isCanary()) {
throw new Error("The CodeQL debugger feature is not available yet.");
}
return new DebugAdapterInlineImplementation(
new QLDebugSession(this.queryStorageDir, this.queryRunner),
);
}
}

View File

@@ -0,0 +1,235 @@
import { basename } from "path";
import {
DebugAdapterTracker,
DebugAdapterTrackerFactory,
DebugSession,
debug,
Uri,
CancellationTokenSource,
} from "vscode";
import { DebuggerCommands } from "../common/commands";
import { DatabaseManager } from "../local-databases";
import { LocalQueries, LocalQueryRun } from "../local-queries";
import { DisposableObject } from "../pure/disposable-object";
import { CoreQueryResults } from "../queryRunner";
import {
getQuickEvalContext,
QueryOutputDir,
validateQueryUri,
} from "../run-queries-shared";
import { QLResolvedDebugConfiguration } from "./debug-configuration";
import * as CodeQLProtocol from "./debug-protocol";
import { App } from "../common/app";
/**
* 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();
constructor(
private readonly session: DebugSession,
private readonly ui: DebuggerUI,
private readonly localQueries: LocalQueries,
private readonly dbm: DatabaseManager,
) {
super();
this.configuration = <QLResolvedDebugConfiguration>session.configuration;
}
public onDidSendMessage(message: CodeQLProtocol.AnyProtocolMessage): void {
if (message.type === "event") {
switch (message.event) {
case "codeql-evaluation-started":
this.queueMessageHandler(() =>
this.onEvaluationStarted(message.body),
);
break;
case "codeql-evaluation-completed":
this.queueMessageHandler(() =>
this.onEvaluationCompleted(message.body),
);
break;
case "output":
if (message.body.category === "console") {
void this.localQueryRun?.logger.log(message.body.output);
}
break;
}
}
}
public onWillStopSession(): void {
this.ui.onSessionClosed(this.session);
this.dispose();
}
public async quickEval(): Promise<void> {
const args: CodeQLProtocol.QuickEvalRequest["arguments"] = {
quickEvalContext: await getQuickEvalContext(undefined),
};
await this.session.customRequest("codeql-quickeval", args);
}
/**
* 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: CodeQLProtocol.EvaluationStartedEvent["body"],
): Promise<void> {
const dbUri = Uri.file(this.configuration.database);
const dbItem = await this.dbm.createOrOpenDatabaseItem(dbUri);
// When cancellation is requested from the query history view, we just stop the debug session.
const tokenSource = new CancellationTokenSource();
tokenSource.token.onCancellationRequested(() =>
debug.stopDebugging(this.session),
);
this.localQueryRun = await this.localQueries.createLocalQueryRun(
{
queryPath: this.configuration.query,
quickEval: body.quickEvalContext,
},
dbItem,
new QueryOutputDir(body.outputDir),
tokenSource,
);
}
/** Update the UI after a query has finished evaluating. */
private async onEvaluationCompleted(
body: CodeQLProtocol.EvaluationCompletedEvent["body"],
): Promise<void> {
if (this.localQueryRun !== undefined) {
const results: CoreQueryResults = body;
await this.localQueryRun.complete(results);
this.localQueryRun = undefined;
}
}
}
/** Service handling the UI for CodeQL debugging. */
export class DebuggerUI
extends DisposableObject
implements DebugAdapterTrackerFactory
{
private readonly sessions = new Map<string, QLDebugAdapterTracker>();
constructor(
private readonly app: App,
private readonly localQueries: LocalQueries,
private readonly dbm: DatabaseManager,
) {
super();
this.push(debug.registerDebugAdapterTrackerFactory("codeql", this));
}
public getCommands(): DebuggerCommands {
return {
"codeQL.debugQuery": this.debugQuery.bind(this),
"codeQL.debugQueryContextEditor": this.debugQuery.bind(this),
"codeQL.startDebuggingSelectionContextEditor":
this.startDebuggingSelection.bind(this),
"codeQL.startDebuggingSelection": this.startDebuggingSelection.bind(this),
"codeQL.continueDebuggingSelection":
this.continueDebuggingSelection.bind(this),
"codeQL.continueDebuggingSelectionContextEditor":
this.continueDebuggingSelection.bind(this),
};
}
public createDebugAdapterTracker(
session: DebugSession,
): DebugAdapterTracker | undefined {
if (session.type === "codeql") {
// The tracker will be disposed in its own `onWillStopSession` handler.
const tracker = new QLDebugAdapterTracker(
session,
this,
this.localQueries,
this.dbm,
);
this.sessions.set(session.id, tracker);
return tracker;
} else {
return undefined;
}
}
public onSessionClosed(session: DebugSession): void {
this.sessions.delete(session.id);
}
private async debugQuery(uri: Uri | undefined): Promise<void> {
const queryPath =
uri !== undefined
? validateQueryUri(uri, false)
: await this.localQueries.getCurrentQuery(false);
// Start debugging with a default configuration that just specifies the query path.
await debug.startDebugging(undefined, {
name: basename(queryPath),
type: "codeql",
request: "launch",
query: queryPath,
});
}
private async startDebuggingSelection(): Promise<void> {
// Launch the currently selected debug configuration, but specifying QuickEval mode.
await this.app.commands.execute("workbench.action.debug.start", {
config: {
quickEval: true,
},
});
}
private async continueDebuggingSelection(): Promise<void> {
const activeTracker = this.activeTracker;
if (activeTracker === undefined) {
throw new Error("No CodeQL debug session is active.");
}
await activeTracker.quickEval();
}
private getTrackerForSession(
session: DebugSession,
): QLDebugAdapterTracker | undefined {
return this.sessions.get(session.id);
}
public get activeTracker(): QLDebugAdapterTracker | undefined {
const session = debug.activeDebugSession;
if (session === undefined) {
return undefined;
}
return this.getTrackerForSession(session);
}
}

View File

@@ -109,6 +109,7 @@ import { VariantAnalysisResultsManager } from "./variant-analysis/variant-analys
import { ExtensionApp } from "./common/vscode/vscode-app";
import { DbModule } from "./databases/db-module";
import { redactableError } from "./pure/errors";
import { QLDebugAdapterDescriptorFactory } from "./debugger/debugger-factory";
import { QueryHistoryDirs } from "./query-history/query-history-dirs";
import {
AllExtensionCommands,
@@ -121,6 +122,7 @@ import { getAstCfgCommands } from "./ast-cfg-commands";
import { getQueryEditorCommands } from "./query-editor";
import { App } from "./common/app";
import { registerCommandWithErrorHandling } from "./common/vscode/commands";
import { DebuggerUI } from "./debugger/debugger-ui";
import { DataExtensionsEditorModule } from "./data-extensions-editor/data-extensions-editor-module";
import { TestManager } from "./test-manager";
import { TestRunner } from "./test-runner";
@@ -877,6 +879,15 @@ async function activateWithInstalledDistribution(
);
ctx.subscriptions.push(localQueries);
void extLogger.log("Initializing debugger factory.");
ctx.subscriptions.push(
new QLDebugAdapterDescriptorFactory(queryStorageDir, qs, localQueries),
);
void extLogger.log("Initializing debugger UI.");
const debuggerUI = new DebuggerUI(app, localQueries, dbm);
ctx.subscriptions.push(debuggerUI);
const dataExtensionsEditorModule =
await DataExtensionsEditorModule.initialize(
ctx,
@@ -963,6 +974,7 @@ async function activateWithInstalledDistribution(
...summaryLanguageSupport.getCommands(),
...testUiCommands,
...mockServer.getCommands(),
...debuggerUI.getCommands(),
};
for (const [commandName, command] of Object.entries(allCommands)) {

View File

@@ -316,7 +316,7 @@ export async function compileAndRunQueryAgainstDatabaseCore(
logger: Logger,
): Promise<CoreQueryResults> {
if (extensionPacks !== undefined && extensionPacks.length > 0) {
await showAndLogWarningMessage(
void showAndLogWarningMessage(
"Legacy query server does not support extension packs.",
);
}

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,
@@ -208,6 +214,7 @@ export class DatabaseUI extends DisposableObject {
public getCommands(): LocalDatabasesCommands {
return {
"codeQL.getCurrentDatabase": this.handleGetCurrentDatabase.bind(this),
"codeQL.chooseDatabaseFolder":
this.handleChooseDatabaseFolderFromPalette.bind(this),
"codeQL.chooseDatabaseArchive":
@@ -254,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(
@@ -415,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(
@@ -602,6 +609,11 @@ export class DatabaseUI extends DisposableObject {
);
}
private async handleGetCurrentDatabase(): Promise<string | undefined> {
const dbItem = await this.getDatabaseItemInternal(undefined);
return dbItem?.databaseUri.fsPath;
}
private async handleSetCurrentDatabase(uri: Uri): Promise<void> {
return withProgress(
async (progress, token) => {
@@ -717,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;
@@ -749,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

@@ -613,12 +613,61 @@ export class DatabaseManager extends DisposableObject {
qs.onStart(this.reregisterDatabases.bind(this));
}
/**
* Creates a {@link DatabaseItem} for the specified database, and adds it to the list of open
* databases.
*/
public async openDatabase(
progress: ProgressCallback,
token: vscode.CancellationToken,
uri: vscode.Uri,
displayName?: string,
isTutorialDatabase?: boolean,
): Promise<DatabaseItem> {
const databaseItem = await this.createDatabaseItem(uri, displayName);
return await this.addExistingDatabaseItem(
databaseItem,
progress,
token,
isTutorialDatabase,
);
}
/**
* Adds a {@link DatabaseItem} to the list of open databases, if that database is not already on
* the list.
*
* Typically, the item will have been created by {@link createOrOpenDatabaseItem} or {@link openDatabase}.
*/
public async addExistingDatabaseItem(
databaseItem: DatabaseItem,
progress: ProgressCallback,
token: vscode.CancellationToken,
isTutorialDatabase?: boolean,
): Promise<DatabaseItem> {
const existingItem = this.findDatabaseItem(databaseItem.databaseUri);
if (existingItem !== undefined) {
return existingItem;
}
await this.addDatabaseItem(progress, token, databaseItem);
await this.addDatabaseSourceArchiveFolder(databaseItem);
if (isCodespacesTemplate() && !isTutorialDatabase) {
await this.createSkeletonPacks(databaseItem);
}
return databaseItem;
}
/**
* Creates a {@link DatabaseItem} for the specified database, without adding it to the list of
* open databases.
*/
private async createDatabaseItem(
uri: vscode.Uri,
displayName: string | undefined,
): Promise<DatabaseItem> {
const contents = await DatabaseResolver.resolveDatabaseContents(uri);
// Ignore the source archive for QLTest databases by default.
@@ -639,14 +688,27 @@ export class DatabaseManager extends DisposableObject {
},
);
await this.addDatabaseItem(progress, token, databaseItem);
await this.addDatabaseSourceArchiveFolder(databaseItem);
return databaseItem;
}
if (isCodespacesTemplate() && !isTutorialDatabase) {
await this.createSkeletonPacks(databaseItem);
/**
* If the specified database is already on the list of open databases, returns that database's
* {@link DatabaseItem}. Otherwise, creates a new {@link DatabaseItem} without adding it to the
* list of open databases.
*
* The {@link DatabaseItem} can be added to the list of open databases later, via {@link addExistingDatabaseItem}.
*/
public async createOrOpenDatabaseItem(
uri: vscode.Uri,
): Promise<DatabaseItem> {
const existingItem = this.findDatabaseItem(uri);
if (existingItem !== undefined) {
// Use the one we already have.
return existingItem;
}
return databaseItem;
// We don't add this to the list automatically, but the user can add it later.
return this.createDatabaseItem(uri, undefined);
}
public async createSkeletonPacks(databaseItem: DatabaseItem) {

View File

@@ -6,6 +6,7 @@ import {
Range,
Uri,
window,
workspace,
} from "vscode";
import { BaseLogger, extLogger, Logger, TeeLogger } from "./common";
import { isCanary, 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";
@@ -75,6 +78,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.
*
@@ -238,6 +260,13 @@ 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": () => {
// When invoked as a command, such as when resolving variables in a debug configuration,
// always allow ".qll" files, because we don't know if the configuration will be for
// quickeval yet. The debug configuration code will do further validation once it knows for
// sure.
return this.getCurrentQuery(true);
},
"codeQL.createQuery": this.createSkeletonQuery.bind(this),
};
}
@@ -377,6 +406,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.
*/
public async getCurrentQuery(allowLibraryFiles: boolean): 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, allowLibraryFiles);
}
private async createSkeletonQuery(): Promise<void> {
await withProgress(
async (progress: ProgressCallback, token: CancellationToken) => {
@@ -470,29 +516,38 @@ 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(quickEval);
}
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");
}
const additionalPacks = getOnDiskWorkspaceFolders();
const extensionPacks = (await this.cliServer.useExtensionPacks())
? Object.keys(await this.cliServer.resolveQlpacks(additionalPacks, true))
: undefined;
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,
@@ -612,4 +667,12 @@ export class LocalQueries extends DisposableObject {
): Promise<void> {
await this.localQueryResultsView.showResults(query, forceReveal, false);
}
public async getDefaultExtensionPacks(
additionalPacks: string[],
): Promise<string[]> {
return (await this.cliServer.useExtensionPacks())
? Object.keys(await this.cliServer.resolveQlpacks(additionalPacks, true))
: [];
}
}

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).trim();
} 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"),

View File

@@ -1 +1,2 @@
.vscode
.vscode/**
!.vscode/launch.json

View File

@@ -0,0 +1,11 @@
// A launch configuration that compiles the extension and then opens it inside a new window
{
"version": "0.2.0",
"configurations": [
{
"name": "simple-query",
"type": "codeql",
"request": "launch"
}
]
}

View File

@@ -7,10 +7,15 @@ type CmdDecl = {
title?: string;
};
type DebuggerDecl = {
variables?: Record<string, string>;
};
describe("commands declared in package.json", () => {
const manifest = readJsonSync(join(__dirname, "../../package.json"));
const commands = manifest.contributes.commands;
const menus = manifest.contributes.menus;
const debuggers = manifest.contributes.debuggers;
const disabledInPalette: Set<string> = new Set<string>();
@@ -60,6 +65,15 @@ describe("commands declared in package.json", () => {
contribContextMenuCmds.add(command);
});
debuggers.forEach((debuggerDecl: DebuggerDecl) => {
if (debuggerDecl.variables !== undefined) {
for (const command of Object.values(debuggerDecl.variables)) {
// Commands used as debug configuration variables need not be enabled in the command palette.
paletteCmds.delete(command);
}
}
});
menus.commandPalette.forEach((commandDecl: CmdDecl) => {
if (commandDecl.when === "false")
disabledInPalette.add(commandDecl.command);
@@ -85,6 +99,9 @@ describe("commands declared in package.json", () => {
it("should have the right commands accessible from the command palette", () => {
paletteCmds.forEach((command) => {
// command ${command} should be enabled in the command palette
if (disabledInPalette.has(command) !== false) {
expect(command).toBe("enabled");
}
expect(disabledInPalette.has(command)).toBe(false);
});

View File

@@ -0,0 +1,20 @@
newtype TNumber = MkNumber(int n) {
n in [0..20]
}
abstract class InterestingNumber extends TNumber
{
int value;
InterestingNumber() {
this = MkNumber(value)
}
string toString() {
result = value.toString()
}
final int getValue() {
result = value
}
}

View File

@@ -0,0 +1,20 @@
import QuickEvalLib
class PrimeNumber extends InterestingNumber {
PrimeNumber() {
exists(int n | this = MkNumber(n) |
n in [
2,
3,
5,
7,
11,
13,
17,
19
])
}
}
from InterestingNumber n
select n.toString()

View File

@@ -0,0 +1,433 @@
import {
DebugAdapterTracker,
DebugAdapterTrackerFactory,
DebugSession,
ProviderResult,
Uri,
debug,
workspace,
} from "vscode";
import * as CodeQLProtocol from "../../../../src/debugger/debug-protocol";
import { DisposableObject } from "../../../../src/pure/disposable-object";
import { QueryResultType } from "../../../../src/pure/legacy-messages";
import { CoreCompletedQuery } from "../../../../src/queryRunner";
import { QueryOutputDir } from "../../../../src/run-queries-shared";
import {
QLDebugArgs,
QLDebugConfiguration,
} from "../../../../src/debugger/debug-configuration";
import { join } from "path";
import { writeFile } from "fs-extra";
import { expect } from "@jest/globals";
import { AppCommandManager } from "../../../../src/common/commands";
import { getOnDiskWorkspaceFolders } from "../../../../src/helpers";
type Resolver<T> = (value: T) => void;
/**
* Listens for Debug Adapter Protocol messages from a particular debug session, and reports the
* interesting events back to the `DebugController`.
*/
class Tracker implements DebugAdapterTracker {
private database: string | undefined;
private queryPath: string | undefined;
private started: CodeQLProtocol.EvaluationStartedEvent["body"] | undefined =
undefined;
private completed:
| CodeQLProtocol.EvaluationCompletedEvent["body"]
| undefined = undefined;
public constructor(
private readonly session: DebugSession,
private readonly controller: DebugController,
) {}
public onWillReceiveMessage(
message: CodeQLProtocol.AnyProtocolMessage,
): void {
switch (message.type) {
case "request":
this.onWillReceiveRequest(message);
break;
}
}
public onDidSendMessage(message: CodeQLProtocol.AnyProtocolMessage): void {
void this.session;
switch (message.type) {
case "event":
this.onDidSendEvent(message);
break;
}
}
private onWillReceiveRequest(request: CodeQLProtocol.AnyRequest): void {
switch (request.command) {
case "launch":
this.controller.handleEvent({
kind: "launched",
request,
});
break;
}
}
private onDidSendEvent(event: CodeQLProtocol.AnyEvent): void {
switch (event.event) {
case "codeql-evaluation-started":
this.started = event.body;
break;
case "codeql-evaluation-completed":
this.completed = event.body;
this.controller.handleEvent({
kind: "evaluationCompleted",
started: this.started!,
results: {
...this.started!,
...this.completed!,
outputDir: new QueryOutputDir(this.started!.outputDir),
queryTarget: {
queryPath: this.queryPath!,
quickEvalPosition:
this.started!.quickEvalContext?.quickEvalPosition,
},
dbPath: this.database!,
},
});
break;
case "exited":
this.controller.handleEvent({
kind: "exited",
body: event.body,
});
break;
case "stopped":
this.controller.handleEvent({
kind: "stopped",
});
break;
}
}
}
/**
* An interesting event from the debug session. These are queued by the `DebugContoller`. The test
* code consumes these events and asserts that they are in the correct order and have the correct
* data.
*/
export type DebugEventKind =
| "launched"
| "evaluationCompleted"
| "terminated"
| "stopped"
| "exited"
| "sessionClosed";
export interface DebugEvent {
kind: DebugEventKind;
}
export interface LaunchedEvent extends DebugEvent {
kind: "launched";
request: CodeQLProtocol.LaunchRequest;
}
export interface EvaluationCompletedEvent extends DebugEvent {
kind: "evaluationCompleted";
started: CodeQLProtocol.EvaluationStartedEvent["body"];
results: CoreCompletedQuery;
}
export interface TerminatedEvent extends DebugEvent {
kind: "terminated";
}
export interface StoppedEvent extends DebugEvent {
kind: "stopped";
}
export interface ExitedEvent extends DebugEvent {
kind: "exited";
body: CodeQLProtocol.ExitedEvent["body"];
}
export interface SessionClosedEvent extends DebugEvent {
kind: "sessionClosed";
}
export type AnyDebugEvent =
| LaunchedEvent
| EvaluationCompletedEvent
| StoppedEvent
| ExitedEvent
| TerminatedEvent
| SessionClosedEvent;
/**
* Exposes a simple facade over a debugging session. Test code invokes the various commands as
* async functions, and consumes events reported by the session to ensure the correct sequence and
* data.
*/
export class DebugController
extends DisposableObject
implements DebugAdapterTrackerFactory
{
/** Queue of events reported by the session. */
private readonly eventQueue: AnyDebugEvent[] = [];
/**
* The index of the next event to be read from the queue. This index may be equal to the length of
* the queue, in which case all events received so far have been consumed, and the next attempt to
* consume an event will block waiting for that event.
* */
private nextEventIndex = 0;
/**
* If the client is currently blocked waiting for a new event, this property holds the `resolve()`
* function that will resolve the promise on which the client is blocked.
*/
private resolver: Resolver<AnyDebugEvent> | undefined = undefined;
public constructor(private readonly appCommands: AppCommandManager) {
super();
this.push(debug.registerDebugAdapterTrackerFactory("codeql", this));
this.push(
debug.onDidTerminateDebugSession(
this.handleDidTerminateDebugSession.bind(this),
),
);
this.push(
debug.onDidChangeActiveDebugSession(
this.handleDidChangeActiveDebugSession.bind(this),
),
);
}
public createDebugAdapterTracker(
session: DebugSession,
): ProviderResult<DebugAdapterTracker> {
return new Tracker(session, this);
}
public async createLaunchJson(config: QLDebugConfiguration): Promise<void> {
const launchJsonPath = join(
getOnDiskWorkspaceFolders()[0],
".vscode/launch.json",
);
await writeFile(
launchJsonPath,
JSON.stringify({
version: "0.2.0",
configurations: [config],
}),
);
}
/**
* Starts a debug session via the "codeQL.debugQuery" copmmand.
*/
public debugQuery(uri: Uri): Promise<void> {
return this.appCommands.execute("codeQL.debugQuery", uri);
}
public async startDebugging(
config: QLDebugArgs,
noDebug = false,
): Promise<void> {
const fullConfig: QLDebugConfiguration = {
...config,
name: "test",
type: "codeql",
request: "launch",
};
const options = noDebug
? {
noDebug: true,
}
: {};
return await this.appCommands.execute("workbench.action.debug.start", {
config: fullConfig,
...options,
});
}
public async startDebuggingSelection(config: QLDebugArgs): Promise<void> {
return await this.startDebugging({
...config,
quickEval: true,
});
}
public async continueDebuggingSelection(): Promise<void> {
return await this.appCommands.execute("codeQL.continueDebuggingSelection");
}
public async stepInto(): Promise<void> {
return await this.appCommands.execute("workbench.action.debug.stepInto");
}
public async stepOver(): Promise<void> {
return await this.appCommands.execute("workbench.action.debug.stepOver");
}
public async stepOut(): Promise<void> {
return await this.appCommands.execute("workbench.action.debug.stepOut");
}
public handleEvent(event: AnyDebugEvent): void {
this.eventQueue.push(event);
if (this.resolver !== undefined) {
// We were waiting for this one. Resolve it.
this.nextEventIndex++;
const resolver = this.resolver;
this.resolver = undefined;
resolver(event);
}
}
private handleDidTerminateDebugSession(_session: DebugSession): void {
this.handleEvent({
kind: "terminated",
});
}
private handleDidChangeActiveDebugSession(
session: DebugSession | undefined,
): void {
if (session === undefined) {
this.handleEvent({
kind: "sessionClosed",
});
}
}
/**
* Consumes the next event in the queue. If all received messages have already been consumed, this
* function blocks until another event is received.
*/
private async nextEvent(): Promise<AnyDebugEvent> {
if (this.resolver !== undefined) {
const error = new Error(
"Attempt to wait for multiple debugger events at once.",
);
fail(error);
throw error;
} else {
if (this.nextEventIndex < this.eventQueue.length) {
// No need to wait.
const event = this.eventQueue[this.nextEventIndex];
this.nextEventIndex++;
return Promise.resolve(event);
} else {
// No event available yet, so we need to wait.
return new Promise((resolve, _reject) => {
this.resolver = resolve;
});
}
}
}
/**
* Consume the next event in the queue, and assert that it is of the specified type.
*/
private async expectEvent<T extends DebugEvent>(kind: T["kind"]): Promise<T> {
const event = await this.nextEvent();
expect(event.kind).toBe(kind);
return <T>event;
}
public async expectLaunched(): Promise<LaunchedEvent> {
return this.expectEvent<LaunchedEvent>("launched");
}
public async expectExited(): Promise<ExitedEvent> {
return this.expectEvent<ExitedEvent>("exited");
}
public async expectCompleted(): Promise<EvaluationCompletedEvent> {
return await this.expectEvent<EvaluationCompletedEvent>(
"evaluationCompleted",
);
}
public async expectSucceeded(): Promise<EvaluationCompletedEvent> {
const event = await this.expectCompleted();
if (event.results.resultType !== QueryResultType.SUCCESS) {
expect(event.results.message).toBe("success");
}
return event;
}
public async expectFailed(): Promise<EvaluationCompletedEvent> {
const event = await this.expectCompleted();
expect(event.results.resultType).not.toEqual(QueryResultType.SUCCESS);
return event;
}
public async expectStopped(): Promise<StoppedEvent> {
return await this.expectEvent<StoppedEvent>("stopped");
}
public async expectTerminated(): Promise<TerminatedEvent> {
return this.expectEvent<TerminatedEvent>("terminated");
}
public async expectSessionClosed(): Promise<SessionClosedEvent> {
return this.expectEvent<SessionClosedEvent>("sessionClosed");
}
/**
* Wait the specified number of milliseconds, and fail the test if any events are received within
* that timeframe.
*/
public async expectNoEvents(duration: number): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (this.nextEventIndex < this.eventQueue.length) {
const event = this.eventQueue[this.nextEventIndex];
reject(
new Error(
`Did not expect to receive any events, but received '${event.kind}'.`,
),
);
} else {
resolve();
}
}, duration);
});
}
}
/**
* Execute a function with a new instance of `DebugContoller`. Once the function completes, the
* debug controller is cleaned up.
*/
export async function withDebugController<T>(
appCommands: AppCommandManager,
op: (controller: DebugController) => Promise<T>,
): Promise<T> {
await workspace.getConfiguration().update("codeQL.canary", true);
try {
const controller = new DebugController(appCommands);
try {
try {
const result = await op(controller);
// The test should have consumed all expected events. Wait a couple seconds to make sure
// no more come in.
await controller.expectNoEvents(2000);
return result;
} finally {
await debug.stopDebugging();
}
} finally {
// In a separate finally block so that the controller gets disposed even if `stopDebugging()`
// fails.
controller.dispose();
}
} finally {
await workspace.getConfiguration().update("codeQL.canary", false);
}
}

View File

@@ -0,0 +1,141 @@
import { Selection, Uri, window, workspace } from "vscode";
import { join } from "path";
import { DatabaseManager } from "../../../../src/local-databases";
import {
cleanDatabases,
ensureTestDatabase,
getActivatedExtension,
} from "../../global.helper";
import { describeWithCodeQL } from "../../cli";
import { withDebugController } from "./debug-controller";
import { CodeQLCliServer } from "../../../../src/cli";
import { QueryOutputDir } from "../../../../src/run-queries-shared";
import { createVSCodeCommandManager } from "../../../../src/common/vscode/commands";
import { AllCommands } from "../../../../src/common/commands";
async function selectForQuickEval(
path: string,
line: number,
column: number,
endLine: number,
endColumn: number,
): Promise<void> {
const document = await workspace.openTextDocument(path);
const editor = await window.showTextDocument(document);
editor.selection = new Selection(line, column, endLine, endColumn);
}
async function getResultCount(
outputDir: QueryOutputDir,
cli: CodeQLCliServer,
): Promise<number> {
const info = await cli.bqrsInfo(outputDir.bqrsPath, 100);
const resultSet = info["result-sets"][0];
return resultSet.rows;
}
/**
* Integration tests for the query debugger
*/
describeWithCodeQL()("Debugger", () => {
let databaseManager: DatabaseManager;
let cli: CodeQLCliServer;
const appCommands = createVSCodeCommandManager<AllCommands>();
const simpleQueryPath = join(__dirname, "..", "data", "simple-query.ql");
const quickEvalQueryPath = join(__dirname, "..", "data", "QuickEvalQuery.ql");
const quickEvalLibPath = join(__dirname, "..", "data", "QuickEvalLib.qll");
beforeEach(async () => {
const extension = await getActivatedExtension();
databaseManager = extension.databaseManager;
cli = extension.cliServer;
cli.quiet = true;
await ensureTestDatabase(databaseManager, cli);
});
afterEach(async () => {
await cleanDatabases(databaseManager);
});
it("should debug a query and keep the session active", async () => {
await withDebugController(appCommands, async (controller) => {
await controller.debugQuery(Uri.file(simpleQueryPath));
await controller.expectLaunched();
await controller.expectSucceeded();
await controller.expectStopped();
});
});
it("should run a query and then stop debugging", async () => {
await withDebugController(appCommands, async (controller) => {
await controller.startDebugging(
{
query: simpleQueryPath,
},
true,
);
await controller.expectLaunched();
await controller.expectSucceeded();
await controller.expectExited();
await controller.expectTerminated();
await controller.expectSessionClosed();
});
});
it("should run a quick evaluation", async () => {
await withDebugController(appCommands, async (controller) => {
await selectForQuickEval(quickEvalQueryPath, 18, 5, 18, 22);
// Don't specify a query path, so we'll default to the active document ("QuickEvalQuery.ql")
await controller.startDebuggingSelection({});
await controller.expectLaunched();
const result = await controller.expectSucceeded();
expect(result.started.quickEvalContext).toBeDefined();
expect(result.started.quickEvalContext!.quickEvalText).toBe(
"InterestingNumber",
);
expect(result.results.queryTarget.quickEvalPosition).toBeDefined();
expect(await getResultCount(result.results.outputDir, cli)).toBe(8);
await controller.expectStopped();
});
});
it("should run a quick evaluation on a library without any query context", async () => {
await withDebugController(appCommands, async (controller) => {
await selectForQuickEval(quickEvalLibPath, 4, 15, 4, 32);
// Don't specify a query path, so we'll default to the active document ("QuickEvalLib.qll")
await controller.startDebuggingSelection({});
await controller.expectLaunched();
const result = await controller.expectSucceeded();
expect(result.started.quickEvalContext).toBeDefined();
expect(result.started.quickEvalContext!.quickEvalText).toBe(
"InterestingNumber",
);
expect(result.results.queryTarget.quickEvalPosition).toBeDefined();
expect(await getResultCount(result.results.outputDir, cli)).toBe(0);
await controller.expectStopped();
});
});
it("should run a quick evaluation on a library in the context of a specific query", async () => {
await withDebugController(appCommands, async (controller) => {
await selectForQuickEval(quickEvalLibPath, 4, 15, 4, 32);
await controller.startDebuggingSelection({
query: quickEvalQueryPath, // The query context. This query extends the abstract class.
});
await controller.expectLaunched();
const result = await controller.expectSucceeded();
expect(result.started.quickEvalContext).toBeDefined();
expect(result.started.quickEvalContext!.quickEvalText).toBe(
"InterestingNumber",
);
expect(result.results.queryTarget.quickEvalPosition).toBeDefined();
expect(await getResultCount(result.results.outputDir, cli)).toBe(8);
await controller.expectStopped();
});
});
});

View File

@@ -5,18 +5,11 @@ import * as messages from "../../../src/pure/new-messages";
import * as qsClient from "../../../src/query-server/queryserver-client";
import * as cli from "../../../src/cli";
import { CellValue } from "../../../src/pure/bqrs-cli-types";
import { Uri } from "vscode";
import { describeWithCodeQL } from "../cli";
import { QueryServerClient } from "../../../src/query-server/queryserver-client";
import { extLogger, ProgressReporter } from "../../../src/common";
import { QueryResultType } from "../../../src/pure/new-messages";
import {
cleanDatabases,
dbLoc,
getActivatedExtension,
storagePath,
} from "../global.helper";
import { importArchiveDatabase } from "../../../src/databaseFetcher";
import { ensureTestDatabase, getActivatedExtension } from "../global.helper";
import { createMockApp } from "../../__mocks__/appMock";
const baseDir = join(__dirname, "../../../test/data");
@@ -142,24 +135,11 @@ describeWithCodeQL()("using the new query server", () => {
await qs.startQueryServer();
// Unlike the old query sevre the new one wants a database and the empty direcrtory is not valid.
// Add a database, but make sure the database manager is empty first
await cleanDatabases(extension.databaseManager);
const uri = Uri.file(dbLoc);
const maybeDbItem = await importArchiveDatabase(
app.commands,
uri.toString(true),
const dbItem = await ensureTestDatabase(
extension.databaseManager,
storagePath,
() => {
/**ignore progress */
},
token,
undefined,
);
if (!maybeDbItem) {
throw new Error("Could not import database");
}
db = maybeDbItem.databaseUri.fsPath;
db = dbItem.databaseUri.fsPath;
});
for (const queryTestCase of queryTestCases) {

View File

@@ -1,4 +1,4 @@
import { CancellationToken, ExtensionContext, Uri } from "vscode";
import { CancellationToken, ExtensionContext, Range, Uri } from "vscode";
import { join, dirname } from "path";
import {
pathExistsSync,
@@ -12,20 +12,68 @@ import { load, dump } from "js-yaml";
import { DatabaseItem, DatabaseManager } from "../../../src/local-databases";
import {
cleanDatabases,
dbLoc,
ensureTestDatabase,
getActivatedExtension,
storagePath,
} from "../global.helper";
import { importArchiveDatabase } from "../../../src/databaseFetcher";
import { CliVersionConstraint, CodeQLCliServer } from "../../../src/cli";
import { describeWithCodeQL } from "../cli";
import { QueryRunner } from "../../../src/queryRunner";
import { CoreCompletedQuery, QueryRunner } from "../../../src/queryRunner";
import { SELECT_QUERY_NAME } from "../../../src/contextual/locationFinder";
import { createMockCommandManager } from "../../__mocks__/commandsMock";
import { LocalQueries } from "../../../src/local-queries";
import { QueryResultType } from "../../../src/pure/new-messages";
import { createVSCodeCommandManager } from "../../../src/common/vscode/commands";
import { AllCommands, QueryServerCommands } from "../../../src/common/commands";
import {
AllCommands,
AppCommandManager,
QueryServerCommands,
} from "../../../src/common/commands";
import { ProgressCallback } from "../../../src/progress";
import { withDebugController } from "./debugger/debug-controller";
type DebugMode = "localQueries" | "debug";
async function compileAndRunQuery(
mode: DebugMode,
appCommands: AppCommandManager,
localQueries: LocalQueries,
quickEval: boolean,
queryUri: Uri,
progress: ProgressCallback,
token: CancellationToken,
databaseItem: DatabaseItem | undefined,
range?: Range,
): Promise<CoreCompletedQuery> {
switch (mode) {
case "localQueries":
return await localQueries.compileAndRunQueryInternal(
quickEval,
queryUri,
progress,
token,
databaseItem,
range,
);
case "debug":
return await withDebugController(appCommands, async (controller) => {
await controller.startDebugging(
{
query: queryUri.fsPath,
},
true,
);
await controller.expectLaunched();
const succeeded = await controller.expectSucceeded();
await controller.expectExited();
await controller.expectTerminated();
await controller.expectSessionClosed();
return succeeded.results;
});
}
}
const MODES: DebugMode[] = ["localQueries", "debug"];
/**
* Integration tests for queries
@@ -71,23 +119,7 @@ describeWithCodeQL()("Queries", () => {
},
} as CancellationToken;
// Add a database, but make sure the database manager is empty first
await cleanDatabases(databaseManager);
const uri = Uri.file(dbLoc);
const maybeDbItem = await importArchiveDatabase(
createMockCommandManager(),
uri.toString(true),
databaseManager,
storagePath,
progress,
token,
cli,
);
if (!maybeDbItem) {
throw new Error("Could not import database");
}
dbItem = maybeDbItem;
dbItem = await ensureTestDatabase(databaseManager, cli);
});
afterEach(async () => {
@@ -96,7 +128,7 @@ describeWithCodeQL()("Queries", () => {
await cleanDatabases(databaseManager);
});
describe("extension packs", () => {
describe.each(MODES)("extension packs (%s)", (mode) => {
const queryUsingExtensionPath = join(
__dirname,
"../..",
@@ -139,7 +171,10 @@ describeWithCodeQL()("Queries", () => {
}
async function runQueryWithExtensions() {
const result = await localQueries.compileAndRunQueryInternal(
const result = await compileAndRunQuery(
mode,
appCommandManager,
localQueries,
false,
Uri.file(queryUsingExtensionPath),
progress,
@@ -167,75 +202,85 @@ describeWithCodeQL()("Queries", () => {
}
});
it("should run a query", async () => {
const queryPath = join(__dirname, "data", "simple-query.ql");
const result = await localQueries.compileAndRunQueryInternal(
false,
Uri.file(queryPath),
progress,
token,
dbItem,
undefined,
);
describe.each(MODES)("running queries (%s)", (mode) => {
it("should run a query", async () => {
const queryPath = join(__dirname, "data", "simple-query.ql");
const result = await compileAndRunQuery(
mode,
appCommandManager,
localQueries,
false,
Uri.file(queryPath),
progress,
token,
dbItem,
undefined,
);
// just check that the query was successful
expect(result.resultType).toBe(QueryResultType.SUCCESS);
// just check that the query was successful
expect(result.resultType).toBe(QueryResultType.SUCCESS);
});
// Asserts a fix for bug https://github.com/github/vscode-codeql/issues/733
it("should restart the database and run a query", async () => {
await appCommandManager.execute("codeQL.restartQueryServer");
const queryPath = join(__dirname, "data", "simple-query.ql");
const result = await compileAndRunQuery(
mode,
appCommandManager,
localQueries,
false,
Uri.file(queryPath),
progress,
token,
dbItem,
undefined,
);
expect(result.resultType).toBe(QueryResultType.SUCCESS);
});
});
// Asserts a fix for bug https://github.com/github/vscode-codeql/issues/733
it("should restart the database and run a query", async () => {
await appCommandManager.execute("codeQL.restartQueryServer");
const queryPath = join(__dirname, "data", "simple-query.ql");
const result = await localQueries.compileAndRunQueryInternal(
false,
Uri.file(queryPath),
progress,
token,
dbItem,
undefined,
);
describe("quick query", () => {
it("should create a quick query", async () => {
await queryServerCommandManager.execute("codeQL.quickQuery");
expect(result.resultType).toBe(QueryResultType.SUCCESS);
});
// should have created the quick query file and query pack file
expect(pathExistsSync(qlFile)).toBe(true);
expect(pathExistsSync(qlpackFile)).toBe(true);
it("should create a quick query", async () => {
await queryServerCommandManager.execute("codeQL.quickQuery");
const qlpackContents: any = await load(readFileSync(qlpackFile, "utf8"));
// Should have chosen the js libraries
expect(qlpackContents.dependencies["codeql/javascript-all"]).toBe("*");
// should have created the quick query file and query pack file
expect(pathExistsSync(qlFile)).toBe(true);
expect(pathExistsSync(qlpackFile)).toBe(true);
// Should also have a codeql-pack.lock.yml file
const packFileToUse = pathExistsSync(qlpackLockFile)
? qlpackLockFile
: oldQlpackLockFile;
const qlpackLock: any = await load(readFileSync(packFileToUse, "utf8"));
expect(!!qlpackLock.dependencies["codeql/javascript-all"].version).toBe(
true,
);
});
const qlpackContents: any = await load(readFileSync(qlpackFile, "utf8"));
// Should have chosen the js libraries
expect(qlpackContents.dependencies["codeql/javascript-all"]).toBe("*");
it("should avoid creating a quick query", async () => {
mkdirpSync(dirname(qlpackFile));
writeFileSync(
qlpackFile,
dump({
name: "quick-query",
version: "1.0.0",
dependencies: {
"codeql/javascript-all": "*",
},
}),
);
writeFileSync(qlFile, "xxx");
await queryServerCommandManager.execute("codeQL.quickQuery");
// Should also have a codeql-pack.lock.yml file
const packFileToUse = pathExistsSync(qlpackLockFile)
? qlpackLockFile
: oldQlpackLockFile;
const qlpackLock: any = await load(readFileSync(packFileToUse, "utf8"));
expect(!!qlpackLock.dependencies["codeql/javascript-all"].version).toBe(
true,
);
});
it("should avoid creating a quick query", async () => {
mkdirpSync(dirname(qlpackFile));
writeFileSync(
qlpackFile,
dump({
name: "quick-query",
version: "1.0.0",
dependencies: {
"codeql/javascript-all": "*",
},
}),
);
writeFileSync(qlFile, "xxx");
await queryServerCommandManager.execute("codeQL.quickQuery");
// should not have created the quick query file because database schema hasn't changed
expect(readFileSync(qlFile, "utf8")).toBe("xxx");
// should not have created the quick query file because database schema hasn't changed
expect(readFileSync(qlFile, "utf8")).toBe("xxx");
});
});
function safeDel(file: string) {

View File

@@ -1,12 +1,19 @@
import { join } from "path";
import { load, dump } from "js-yaml";
import { realpathSync, readFileSync, writeFileSync } from "fs-extra";
import { CancellationToken, extensions } from "vscode";
import { DatabaseManager } from "../../src/local-databases";
import {
CancellationToken,
CancellationTokenSource,
Uri,
extensions,
} from "vscode";
import { DatabaseItem, DatabaseManager } from "../../src/local-databases";
import { CodeQLCliServer } from "../../src/cli";
import { removeWorkspaceRefs } from "../../src/variant-analysis/run-remote-query";
import { CodeQLExtensionInterface } from "../../src/extension";
import { ProgressCallback } from "../../src/progress";
import { importArchiveDatabase } from "../../src/databaseFetcher";
import { createMockCommandManager } from "../__mocks__/commandsMock";
// This file contains helpers shared between tests that work with an activated extension.
@@ -21,6 +28,35 @@ export const dbLoc = join(
);
export let storagePath: string;
/**
* Removes any existing databases from the database panel, and loads the test database.
*/
export async function ensureTestDatabase(
databaseManager: DatabaseManager,
cli: CodeQLCliServer | undefined,
): Promise<DatabaseItem> {
// Add a database, but make sure the database manager is empty first
await cleanDatabases(databaseManager);
const uri = Uri.file(dbLoc);
const maybeDbItem = await importArchiveDatabase(
createMockCommandManager(),
uri.toString(true),
databaseManager,
storagePath,
(_p) => {
/**/
},
new CancellationTokenSource().token,
cli,
);
if (!maybeDbItem) {
throw new Error("Could not import database");
}
return maybeDbItem;
}
export function setStoragePath(path: string) {
storagePath = path;
}

View File

@@ -1,7 +1,10 @@
import { resolve, join } from "path";
import * as vscode from "vscode";
import { Uri } from "vscode";
import { determineSelectedQuery } from "../../../src/run-queries-shared";
import {
getQuickEvalContext,
validateQueryUri,
} from "../../../src/run-queries-shared";
async function showQlDocument(name: string): Promise<vscode.TextDocument> {
const folderPath = vscode.workspace.workspaceFolders![0].uri.fsPath;
@@ -14,43 +17,47 @@ async function showQlDocument(name: string): Promise<vscode.TextDocument> {
export function run() {
describe("Determining selected query", () => {
it("should allow ql files to be queried", async () => {
const q = await determineSelectedQuery(
const queryPath = validateQueryUri(
Uri.parse("file:///tmp/queryname.ql"),
false,
);
expect(q.queryPath).toBe(join("/", "tmp", "queryname.ql"));
expect(q.quickEvalPosition).toBeUndefined();
expect(queryPath).toBe(join("/", "tmp", "queryname.ql"));
});
it("should allow ql files to be quick-evaled", async () => {
const doc = await showQlDocument("query.ql");
const q = await determineSelectedQuery(doc.uri, true);
await showQlDocument("query.ql");
const q = await getQuickEvalContext(undefined);
expect(
q.queryPath.endsWith(join("ql-vscode", "test", "data", "query.ql")),
q.quickEvalPosition.fileName.endsWith(
join("ql-vscode", "test", "data", "query.ql"),
),
).toBe(true);
});
it("should allow qll files to be quick-evaled", async () => {
const doc = await showQlDocument("library.qll");
const q = await determineSelectedQuery(doc.uri, true);
await showQlDocument("library.qll");
const q = await getQuickEvalContext(undefined);
expect(
q.queryPath.endsWith(join("ql-vscode", "test", "data", "library.qll")),
q.quickEvalPosition.fileName.endsWith(
join("ql-vscode", "test", "data", "library.qll"),
),
).toBe(true);
});
it("should reject non-ql files when running a query", async () => {
await expect(
determineSelectedQuery(Uri.parse("file:///tmp/queryname.txt"), false),
).rejects.toThrow("The selected resource is not a CodeQL query file");
await expect(
determineSelectedQuery(Uri.parse("file:///tmp/queryname.qll"), false),
).rejects.toThrow("The selected resource is not a CodeQL query file");
expect(() =>
validateQueryUri(Uri.parse("file:///tmp/queryname.txt"), false),
).toThrow("The selected resource is not a CodeQL query file");
expect(() =>
validateQueryUri(Uri.parse("file:///tmp/queryname.qll"), false),
).toThrow("The selected resource is not a CodeQL query file");
});
it("should reject non-ql[l] files when running a quick eval", async () => {
await expect(
determineSelectedQuery(Uri.parse("file:///tmp/queryname.txt"), true),
).rejects.toThrow("The selected resource is not a CodeQL file");
await showQlDocument("textfile.txt");
await expect(getQuickEvalContext(undefined)).rejects.toThrow(
"The selected resource is not a CodeQL file",
);
});
});
}