Extract shared components out of interface.ts

This is in preparation for creating a new webview, extract shared
functionality to the webview-utils file
This commit is contained in:
Andrew Eisenberg 2020-05-29 13:44:36 -07:00
Родитель e38a34edce
Коммит 2ab4c1ac14
3 изменённых файлов: 352 добавлений и 258 удалений

Просмотреть файл

@ -21,7 +21,8 @@ import {
import * as helpers from './helpers';
import { assertNever } from './helpers-pure';
import { spawnIdeServer } from './ide-server';
import { InterfaceManager, WebviewReveal } from './interface';
import { InterfaceManager } from './interface';
import { WebviewReveal } from './webview-utils';
import { ideServerLogger, logger, queryServerLogger } from './logging';
import { QueryHistoryManager } from './query-history';
import { CompletedQuery } from './query-results';
@ -30,6 +31,7 @@ import { displayQuickQuery } from './quick-query';
import { compileAndRunQueryAgainstDatabase, tmpDirDisposal, UserCancellationException } from './run-queries';
import { QLTestAdapterFactory } from './test-adapter';
import { TestUIService } from './test-ui';
import { CompareInterfaceManager } from './compare/compare-interface';
/**
* extension.ts
@ -303,14 +305,24 @@ async function activateWithInstalledDistribution(ctx: ExtensionContext, distribu
const qhm = new QueryHistoryManager(
ctx,
queryHistoryConfigurationListener,
async item => showResultsForCompletedQuery(item, WebviewReveal.Forced)
async item => showResultsForCompletedQuery(item, WebviewReveal.Forced),
async (from: CompletedQuery, to: CompletedQuery) => showResultsForComparison(from, to, WebviewReveal.Forced),
);
logger.log('Initializing results panel interface.');
const intm = new InterfaceManager(ctx, dbm, cliServer, queryServerLogger);
ctx.subscriptions.push(intm);
logger.log('Initializing compare panel interface.');
const cmpm = new CompareInterfaceManager(ctx, dbm, cliServer, queryServerLogger);
ctx.subscriptions.push(cmpm);
logger.log('Initializing source archive filesystem provider.');
archiveFilesystemProvider.activate(ctx);
async function showResultsForComparison(from: CompletedQuery, to: CompletedQuery, forceReveal: WebviewReveal): Promise<void> {
await cmpm.showResults(from, to, forceReveal);
}
async function showResultsForCompletedQuery(query: CompletedQuery, forceReveal: WebviewReveal): Promise<void> {
await intm.showResults(query, forceReveal, false);
}

Просмотреть файл

@ -1,25 +1,56 @@
import * as crypto from 'crypto';
import * as path from 'path';
import * as Sarif from 'sarif';
import { FivePartLocation, LocationStyle, LocationValue, ResolvableLocationValue, tryGetResolvableLocation, WholeFileLocation } from 'semmle-bqrs';
import { DisposableObject } from '@github/codeql-vscode-utils';
import * as vscode from 'vscode';
import { Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, languages, Location, Range, Uri, window as Window, workspace, env } from 'vscode';
import * as cli from './cli';
import { CodeQLCliServer } from './cli';
import { DatabaseItem, DatabaseManager } from './databases';
import { showAndLogErrorMessage } from './helpers';
import { assertNever } from './helpers-pure';
import { FromResultsViewMsg, Interpretation, INTERPRETED_RESULTS_PER_RUN_LIMIT, IntoResultsViewMsg, QueryMetadata, ResultsPaths, SortedResultSetInfo, SortedResultsMap, InterpretedResultsSortState, SortDirection, RAW_RESULTS_PAGE_SIZE } from './interface-types';
import { Logger } from './logging';
import * as messages from './messages';
import { CompletedQuery, interpretResults } from './query-results';
import { QueryInfo, tmpDir } from './run-queries';
import { parseSarifLocation, parseSarifPlainTextMessage } from './sarif-utils';
import { adaptSchema, adaptBqrs, RawResultSet, ParsedResultSets } from './adapt';
import { EXPERIMENTAL_BQRS_SETTING } from './config';
import { getDefaultResultSetName } from './interface-utils';
import * as path from "path";
import * as Sarif from "sarif";
import { DisposableObject } from "@github/codeql-vscode-utils";
import * as vscode from "vscode";
import {
Diagnostic,
DiagnosticRelatedInformation,
DiagnosticSeverity,
languages,
Uri,
window as Window,
env
} from "vscode";
import * as cli from "./cli";
import { CodeQLCliServer } from "./cli";
import { DatabaseItem, DatabaseManager } from "./databases";
import { showAndLogErrorMessage } from "./helpers";
import { assertNever } from "./helpers-pure";
import {
FromResultsViewMsg,
Interpretation,
INTERPRETED_RESULTS_PER_RUN_LIMIT,
IntoResultsViewMsg,
QueryMetadata,
ResultsPaths,
SortedResultSetInfo,
SortedResultsMap,
InterpretedResultsSortState,
SortDirection,
RAW_RESULTS_PAGE_SIZE,
} from "./interface-types";
import { Logger } from "./logging";
import * as messages from "./messages";
import { CompletedQuery, interpretResults } from "./query-results";
import { QueryInfo, tmpDir } from "./run-queries";
import { parseSarifLocation, parseSarifPlainTextMessage } from "./sarif-utils";
import {
adaptSchema,
adaptBqrs,
ParsedResultSets,
RawResultSet,
} from "./adapt";
import { EXPERIMENTAL_BQRS_SETTING } from "./config";
import {
WebviewReveal,
fileUriToWebviewUri,
tryResolveLocation,
getHtmlForWebview,
shownLocationDecoration,
shownLocationLineDecoration,
showLocation,
} from "./webview-utils";
import { getDefaultResultSetName } from "./interface-utils";
/**
* interface.ts
@ -29,87 +60,30 @@ import { getDefaultResultSetName } from './interface-utils';
* webview asks us to.
*/
/** Gets a nonce string created with 128 bits of entropy. */
function getNonce(): string {
return crypto.randomBytes(16).toString('base64');
}
/**
* Whether to force webview to reveal
*/
export enum WebviewReveal {
Forced,
NotForced,
}
/**
* Returns HTML to populate the given webview.
* Uses a content security policy that only loads the given script.
*/
function getHtmlForWebview(
webview: vscode.Webview,
scriptUriOnDisk: vscode.Uri,
stylesheetUriOnDisk: vscode.Uri
): void {
// Convert the on-disk URIs into webview URIs.
const scriptWebviewUri = webview.asWebviewUri(scriptUriOnDisk);
const stylesheetWebviewUri = webview.asWebviewUri(stylesheetUriOnDisk);
// Use a nonce in the content security policy to uniquely identify the above resources.
const nonce = getNonce();
/*
* Content security policy:
* default-src: allow nothing by default.
* script-src: allow only the given script, using the nonce.
* style-src: allow only the given stylesheet, using the nonce.
* connect-src: only allow fetch calls to webview resource URIs
* (this is used to load BQRS result files).
*/
const html = `
<html>
<head>
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src ${webview.cspSource};">
<link nonce="${nonce}" rel="stylesheet" href="${stylesheetWebviewUri}">
</head>
<body>
<div id=root>
</div>
<script nonce="${nonce}" src="${scriptWebviewUri}">
</script>
</body>
</html>`;
webview.html = html;
}
/** Converts a filesystem URI into a webview URI string that the given panel can use to read the file. */
export function fileUriToWebviewUri(panel: vscode.WebviewPanel, fileUriOnDisk: Uri): string {
return panel.webview.asWebviewUri(fileUriOnDisk).toString();
}
/** Converts a URI string received from a webview into a local filesystem URI for the same resource. */
export function webviewUriToFileUri(webviewUri: string): Uri {
// Webview URIs used the vscode-resource scheme. The filesystem path of the resource can be obtained from the path component of the webview URI.
const path = Uri.parse(webviewUri).path;
// For this path to be interpreted on the filesystem, we need to parse it as a filesystem URI for the current platform.
return Uri.file(path);
}
function sortMultiplier(sortDirection: SortDirection): number {
switch (sortDirection) {
case SortDirection.asc: return 1;
case SortDirection.desc: return -1;
case SortDirection.asc:
return 1;
case SortDirection.desc:
return -1;
}
}
function sortInterpretedResults(results: Sarif.Result[], sortState: InterpretedResultsSortState | undefined): void {
function sortInterpretedResults(
results: Sarif.Result[],
sortState: InterpretedResultsSortState | undefined
): void {
if (sortState !== undefined) {
const multiplier = sortMultiplier(sortState.sortDirection);
switch (sortState.sortBy) {
case 'alert-message':
case "alert-message":
results.sort((a, b) =>
a.message.text === undefined ? 0 :
b.message.text === undefined ? 0 :
multiplier * (a.message.text?.localeCompare(b.message.text, env.language)));
a.message.text === undefined
? 0
: b.message.text === undefined
? 0
: multiplier * a.message.text?.localeCompare(b.message.text, env.language)
);
break;
default:
assertNever(sortState.sortBy);
@ -145,7 +119,7 @@ export class InterfaceManager extends DisposableObject {
this.handleSelectionChange.bind(this)
)
);
logger.log('Registering path-step navigation commands.');
logger.log("Registering path-step navigation commands.");
this.push(
vscode.commands.registerCommand(
'codeQLQueryResults.nextPathStep',
@ -184,9 +158,7 @@ export class InterfaceManager extends DisposableObject {
}
));
this._panel.onDidDispose(
() => {
this._panel = undefined;
},
() => (this._panel = undefined),
null,
ctx.subscriptions
);
@ -196,13 +168,13 @@ export class InterfaceManager extends DisposableObject {
const stylesheetPathOnDisk = vscode.Uri.file(
ctx.asAbsolutePath('out/resultsView.css')
);
getHtmlForWebview(
panel.webview.html = getHtmlForWebview(
panel.webview,
scriptPathOnDisk,
stylesheetPathOnDisk
);
panel.webview.onDidReceiveMessage(
async e => this.handleMsgFromView(e),
async (e) => this.handleMsgFromView(e),
undefined,
ctx.subscriptions
);
@ -222,16 +194,10 @@ export class InterfaceManager extends DisposableObject {
// Notify the webview that it should expect new results.
await this.postMessage({ t: 'resultsUpdating' });
await update(this._displayedQuery);
await this.showResults(
this._displayedQuery,
WebviewReveal.NotForced,
true
);
await this.showResults(this._displayedQuery, WebviewReveal.NotForced, true);
}
private async handleMsgFromView(
msg: FromResultsViewMsg
): Promise<void> {
private async handleMsgFromView(msg: FromResultsViewMsg): Promise<void> {
switch (msg.t) {
case 'viewSourceFile': {
const databaseItem = this.databaseManager.findDatabaseItem(
@ -247,9 +213,7 @@ export class InterfaceManager extends DisposableObject {
'Original file of this result is not in the database\'s source archive.'
);
} else {
this.logger.log(
`Unable to handleMsgFromView: ${e.message}`
);
this.logger.log(`Unable to handleMsgFromView: ${e.message}`);
}
} else {
this.logger.log(`Unable to handleMsgFromView: ${e}`);
@ -278,7 +242,7 @@ export class InterfaceManager extends DisposableObject {
}
case 'resultViewLoaded':
this._panelLoaded = true;
this._panelLoadedCallBacks.forEach(cb => cb());
this._panelLoadedCallBacks.forEach((cb) => cb());
this._panelLoadedCallBacks = [];
break;
case 'changeSort':
@ -308,7 +272,7 @@ export class InterfaceManager extends DisposableObject {
}
private waitForPanelLoaded(): Promise<void> {
return new Promise(resolve => {
return new Promise((resolve) => {
if (this._panelLoaded) {
resolve();
} else {
@ -318,14 +282,14 @@ export class InterfaceManager extends DisposableObject {
}
/**
* Show query results in webview panel.
* @param results Evaluation info for the executed query.
* @param shouldKeepOldResultsWhileRendering Should keep old results while rendering.
* @param forceReveal Force the webview panel to be visible and
* Appropriate when the user has just performed an explicit
* UI interaction requesting results, e.g. clicking on a query
* history entry.
*/
* Show query results in webview panel.
* @param results Evaluation info for the executed query.
* @param shouldKeepOldResultsWhileRendering Should keep old results while rendering.
* @param forceReveal Force the webview panel to be visible and
* Appropriate when the user has just performed an explicit
* UI interaction requesting results, e.g. clicking on a query
* history entry.
*/
public async showResults(
results: CompletedQuery,
forceReveal: WebviewReveal,
@ -343,9 +307,7 @@ export class InterfaceManager extends DisposableObject {
const sortedResultsMap: SortedResultsMap = {};
results.sortedResultsInfo.forEach(
(v, k) =>
(sortedResultsMap[k] = this.convertPathPropertiesToWebviewUris(
v
))
(sortedResultsMap[k] = this.convertPathPropertiesToWebviewUris(v))
);
this._displayedQuery = results;
@ -363,12 +325,14 @@ export class InterfaceManager extends DisposableObject {
const showButton = 'View Results';
const queryName = results.queryName;
const resultPromise = vscode.window.showInformationMessage(
`Finished running query ${queryName.length > 0 ? ` "${queryName}"` : ''}.`,
`Finished running query ${
queryName.length > 0 ? ` "${queryName}"` : ""
}.`,
showButton
);
// Address this click asynchronously so we still update the
// query history immediately.
resultPromise.then(result => {
resultPromise.then((result) => {
if (result === showButton) {
panel.reveal();
}
@ -377,33 +341,44 @@ export class InterfaceManager extends DisposableObject {
const getParsedResultSets = async (): Promise<ParsedResultSets> => {
if (EXPERIMENTAL_BQRS_SETTING.getValue()) {
const schemas = await this.cliServer.bqrsInfo(results.query.resultsPaths.resultsPath, RAW_RESULTS_PAGE_SIZE);
const schemas = await this.cliServer.bqrsInfo(
results.query.resultsPaths.resultsPath,
RAW_RESULTS_PAGE_SIZE
);
const resultSetNames = schemas['result-sets'].map(resultSet => resultSet.name);
const resultSetNames = schemas["result-sets"].map(
(resultSet) => resultSet.name
);
// This may not wind up being the page we actually show, if there are interpreted results,
// but speculatively send it anyway.
const selectedTable = getDefaultResultSetName(resultSetNames);
const schema = schemas['result-sets'].find(resultSet => resultSet.name == selectedTable)!;
const schema = schemas["result-sets"].find(
(resultSet) => resultSet.name == selectedTable
)!;
if (schema === undefined) {
return { t: 'WebviewParsed' };
return { t: "WebviewParsed" };
}
const chunk = await this.cliServer.bqrsDecode(results.query.resultsPaths.resultsPath, schema.name, RAW_RESULTS_PAGE_SIZE, schema.pagination?.offsets[0]);
const chunk = await this.cliServer.bqrsDecode(
results.query.resultsPaths.resultsPath,
schema.name,
RAW_RESULTS_PAGE_SIZE,
schema.pagination?.offsets[0]
);
const adaptedSchema = adaptSchema(schema);
const resultSet = adaptBqrs(adaptedSchema, chunk);
return {
t: 'ExtensionParsed',
t: "ExtensionParsed",
pageNumber: 0,
numPages: numPagesOfResultSet(resultSet),
resultSet,
selectedTable: undefined,
resultSetNames
resultSetNames,
};
}
else {
return { t: 'WebviewParsed' };
} else {
return { t: "WebviewParsed" };
}
};
@ -418,46 +393,59 @@ export class InterfaceManager extends DisposableObject {
sortedResultsMap,
database: results.database,
shouldKeepOldResultsWhileRendering,
metadata: results.query.metadata
metadata: results.query.metadata,
});
}
/**
* Show a page of raw results from the chosen table.
*/
public async showPageOfResults(selectedTable: string, pageNumber: number): Promise<void> {
public async showPageOfResults(
selectedTable: string,
pageNumber: number
): Promise<void> {
const results = this._displayedQuery;
if (results === undefined) {
throw new Error('trying to view a page of a query that is not loaded');
throw new Error("trying to view a page of a query that is not loaded");
}
const sortedResultsMap: SortedResultsMap = {};
results.sortedResultsInfo.forEach(
(v, k) =>
(sortedResultsMap[k] = this.convertPathPropertiesToWebviewUris(
v
))
(sortedResultsMap[k] = this.convertPathPropertiesToWebviewUris(v))
);
const schemas = await this.cliServer.bqrsInfo(results.query.resultsPaths.resultsPath, RAW_RESULTS_PAGE_SIZE);
const schemas = await this.cliServer.bqrsInfo(
results.query.resultsPaths.resultsPath,
RAW_RESULTS_PAGE_SIZE
);
const resultSetNames = schemas['result-sets'].map(resultSet => resultSet.name);
const resultSetNames = schemas["result-sets"].map(
(resultSet) => resultSet.name
);
const schema = schemas['result-sets'].find(resultSet => resultSet.name == selectedTable)!;
const schema = schemas["result-sets"].find(
(resultSet) => resultSet.name == selectedTable
)!;
if (schema === undefined)
throw new Error(`Query result set '${selectedTable}' not found.`);
const chunk = await this.cliServer.bqrsDecode(results.query.resultsPaths.resultsPath, schema.name, RAW_RESULTS_PAGE_SIZE, schema.pagination?.offsets[pageNumber]);
const chunk = await this.cliServer.bqrsDecode(
results.query.resultsPaths.resultsPath,
schema.name,
RAW_RESULTS_PAGE_SIZE,
schema.pagination?.offsets[pageNumber]
);
const adaptedSchema = adaptSchema(schema);
const resultSet = adaptBqrs(adaptedSchema, chunk);
const parsedResultSets: ParsedResultSets = {
t: 'ExtensionParsed',
t: "ExtensionParsed",
pageNumber,
resultSet,
numPages: numPagesOfResultSet(resultSet),
selectedTable: selectedTable,
resultSetNames
resultSetNames,
};
await this.postMessage({
@ -471,7 +459,7 @@ export class InterfaceManager extends DisposableObject {
sortedResultsMap,
database: results.database,
shouldKeepOldResultsWhileRendering: false,
metadata: results.query.metadata
metadata: results.query.metadata,
});
}
@ -496,16 +484,13 @@ export class InterfaceManager extends DisposableObject {
// unresponsive.
let numTruncatedResults = 0;
sarif.runs.forEach(run => {
sarif.runs.forEach((run) => {
if (run.results !== undefined) {
sortInterpretedResults(run.results, sortState);
if (run.results.length > INTERPRETED_RESULTS_PER_RUN_LIMIT) {
numTruncatedResults +=
run.results.length - INTERPRETED_RESULTS_PER_RUN_LIMIT;
run.results = run.results.slice(
0,
INTERPRETED_RESULTS_PER_RUN_LIMIT
);
run.results = run.results.slice(0, INTERPRETED_RESULTS_PER_RUN_LIMIT);
}
}
});
@ -513,7 +498,7 @@ export class InterfaceManager extends DisposableObject {
sarif,
sourceLocationPrefix,
numTruncatedResults,
sortState
sortState,
};
}
@ -535,9 +520,9 @@ export class InterfaceManager extends DisposableObject {
sourceArchiveUri === undefined
? undefined
: {
sourceArchive: sourceArchiveUri.fsPath,
sourceLocationPrefix
};
sourceArchive: sourceArchiveUri.fsPath,
sourceLocationPrefix,
};
interpretation = await this.getTruncatedResults(
query.metadata,
query.resultsPaths,
@ -569,9 +554,9 @@ export class InterfaceManager extends DisposableObject {
sourceArchiveUri === undefined
? undefined
: {
sourceArchive: sourceArchiveUri.fsPath,
sourceLocationPrefix
};
sourceArchive: sourceArchiveUri.fsPath,
sourceLocationPrefix,
};
const interpretation = await this.getTruncatedResults(
metadata,
resultsInfo,
@ -581,10 +566,7 @@ export class InterfaceManager extends DisposableObject {
);
try {
await this.showProblemResultsAsDiagnostics(
interpretation,
database
);
await this.showProblemResultsAsDiagnostics(interpretation, database);
} catch (e) {
const msg = e instanceof Error ? e.message : e.toString();
this.logger.log(
@ -687,7 +669,7 @@ export class InterfaceManager extends DisposableObject {
): SortedResultSetInfo {
return {
resultsPath: this.convertPathToWebviewUri(info.resultsPath),
sortState: info.sortState
sortState: info.sortState,
};
}
@ -704,93 +686,3 @@ export class InterfaceManager extends DisposableObject {
}
}
}
const findMatchBackground = new vscode.ThemeColor('editor.findMatchBackground');
const findRangeHighlightBackground = new vscode.ThemeColor('editor.findRangeHighlightBackground');
const shownLocationDecoration = vscode.window.createTextEditorDecorationType({
backgroundColor: findMatchBackground,
});
const shownLocationLineDecoration = vscode.window.createTextEditorDecorationType({
backgroundColor: findRangeHighlightBackground,
isWholeLine: true
});
async function showLocation(loc: ResolvableLocationValue, databaseItem: DatabaseItem): Promise<void> {
const resolvedLocation = tryResolveLocation(loc, databaseItem);
if (resolvedLocation) {
const doc = await workspace.openTextDocument(resolvedLocation.uri);
const editorsWithDoc = Window.visibleTextEditors.filter(e => e.document === doc);
const editor = editorsWithDoc.length > 0
? editorsWithDoc[0]
: await Window.showTextDocument(doc, vscode.ViewColumn.One);
const range = resolvedLocation.range;
// When highlighting the range, vscode's occurrence-match and bracket-match highlighting will
// trigger based on where we place the cursor/selection, and will compete for the user's attention.
// For reference:
// - Occurences are highlighted when the cursor is next to or inside a word or a whole word is selected.
// - Brackets are highlighted when the cursor is next to a bracket and there is an empty selection.
// - Multi-line selections explicitly highlight line-break characters, but multi-line decorators do not.
//
// For single-line ranges, select the whole range, mainly to disable bracket highlighting.
// For multi-line ranges, place the cursor at the beginning to avoid visual artifacts from selected line-breaks.
// Multi-line ranges are usually large enough to overshadow the noise from bracket highlighting.
const selectionEnd = (range.start.line === range.end.line)
? range.end
: range.start;
editor.selection = new vscode.Selection(range.start, selectionEnd);
editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
editor.setDecorations(shownLocationDecoration, [range]);
editor.setDecorations(shownLocationLineDecoration, [range]);
}
}
/**
* Resolves the specified CodeQL location to a URI into the source archive.
* @param loc CodeQL location to resolve. Must have a non-empty value for `loc.file`.
* @param databaseItem Database in which to resolve the file location.
*/
function resolveFivePartLocation(loc: FivePartLocation, databaseItem: DatabaseItem): Location {
// `Range` is a half-open interval, and is zero-based. CodeQL locations are closed intervals, and
// are one-based. Adjust accordingly.
const range = new Range(Math.max(0, loc.lineStart - 1),
Math.max(0, loc.colStart - 1),
Math.max(0, loc.lineEnd - 1),
Math.max(0, loc.colEnd));
return new Location(databaseItem.resolveSourceFile(loc.file), range);
}
/**
* Resolves the specified CodeQL filesystem resource location to a URI into the source archive.
* @param loc CodeQL location to resolve, corresponding to an entire filesystem resource. Must have a non-empty value for `loc.file`.
* @param databaseItem Database in which to resolve the filesystem resource location.
*/
function resolveWholeFileLocation(loc: WholeFileLocation, databaseItem: DatabaseItem): Location {
// A location corresponding to the start of the file.
const range = new Range(0, 0, 0, 0);
return new Location(databaseItem.resolveSourceFile(loc.file), range);
}
/**
* Try to resolve the specified CodeQL location to a URI into the source archive. If no exact location
* can be resolved, returns `undefined`.
* @param loc CodeQL location to resolve
* @param databaseItem Database in which to resolve the file location.
*/
function tryResolveLocation(loc: LocationValue | undefined,
databaseItem: DatabaseItem): Location | undefined {
const resolvableLoc = tryGetResolvableLocation(loc);
if (resolvableLoc === undefined) {
return undefined;
}
switch (resolvableLoc.t) {
case LocationStyle.FivePart:
return resolveFivePartLocation(resolvableLoc, databaseItem);
case LocationStyle.WholeFile:
return resolveWholeFileLocation(resolvableLoc, databaseItem);
default:
return undefined;
}
}

Просмотреть файл

@ -0,0 +1,190 @@
import * as crypto from "crypto";
import { Uri, Location, Range, WebviewPanel, Webview, window as Window, workspace, ViewColumn, Selection, TextEditorRevealType, ThemeColor } from "vscode";
import {
FivePartLocation,
LocationStyle,
LocationValue,
tryGetResolvableLocation,
WholeFileLocation,
ResolvableLocationValue,
} from "semmle-bqrs";
import { DatabaseItem } from "./databases";
/** Gets a nonce string created with 128 bits of entropy. */
export function getNonce(): string {
return crypto.randomBytes(16).toString("base64");
}
/**
* Whether to force webview to reveal
*/
export enum WebviewReveal {
Forced,
NotForced,
}
/** Converts a filesystem URI into a webview URI string that the given panel can use to read the file. */
export function fileUriToWebviewUri(
panel: WebviewPanel,
fileUriOnDisk: Uri
): string {
return panel.webview.asWebviewUri(fileUriOnDisk).toString();
}
/** Converts a URI string received from a webview into a local filesystem URI for the same resource. */
export function webviewUriToFileUri(webviewUri: string): Uri {
// Webview URIs used the vscode-resource scheme. The filesystem path of the resource can be obtained from the path component of the webview URI.
const path = Uri.parse(webviewUri).path;
// For this path to be interpreted on the filesystem, we need to parse it as a filesystem URI for the current platform.
return Uri.file(path);
}
/**
* Resolves the specified CodeQL location to a URI into the source archive.
* @param loc CodeQL location to resolve. Must have a non-empty value for `loc.file`.
* @param databaseItem Database in which to resolve the file location.
*/
export function resolveFivePartLocation(
loc: FivePartLocation,
databaseItem: DatabaseItem
): Location {
// `Range` is a half-open interval, and is zero-based. CodeQL locations are closed intervals, and
// are one-based. Adjust accordingly.
const range = new Range(
Math.max(0, loc.lineStart - 1),
Math.max(0, loc.colStart - 1),
Math.max(0, loc.lineEnd - 1),
Math.max(0, loc.colEnd)
);
return new Location(databaseItem.resolveSourceFile(loc.file), range);
}
/**
* Resolves the specified CodeQL filesystem resource location to a URI into the source archive.
* @param loc CodeQL location to resolve, corresponding to an entire filesystem resource. Must have a non-empty value for `loc.file`.
* @param databaseItem Database in which to resolve the filesystem resource location.
*/
export function resolveWholeFileLocation(
loc: WholeFileLocation,
databaseItem: DatabaseItem
): Location {
// A location corresponding to the start of the file.
const range = new Range(0, 0, 0, 0);
return new Location(databaseItem.resolveSourceFile(loc.file), range);
}
/**
* Try to resolve the specified CodeQL location to a URI into the source archive. If no exact location
* can be resolved, returns `undefined`.
* @param loc CodeQL location to resolve
* @param databaseItem Database in which to resolve the file location.
*/
export function tryResolveLocation(
loc: LocationValue | undefined,
databaseItem: DatabaseItem
): Location | undefined {
const resolvableLoc = tryGetResolvableLocation(loc);
if (resolvableLoc === undefined) {
return undefined;
}
switch (resolvableLoc.t) {
case LocationStyle.FivePart:
return resolveFivePartLocation(resolvableLoc, databaseItem);
case LocationStyle.WholeFile:
return resolveWholeFileLocation(resolvableLoc, databaseItem);
default:
return undefined;
}
}
/**
* Returns HTML to populate the given webview.
* Uses a content security policy that only loads the given script.
*/
export function getHtmlForWebview(
webview: Webview,
scriptUriOnDisk: Uri,
stylesheetUriOnDisk: Uri
): string {
// Convert the on-disk URIs into webview URIs.
const scriptWebviewUri = webview.asWebviewUri(scriptUriOnDisk);
const stylesheetWebviewUri = webview.asWebviewUri(stylesheetUriOnDisk);
// Use a nonce in the content security policy to uniquely identify the above resources.
const nonce = getNonce();
/*
* Content security policy:
* default-src: allow nothing by default.
* script-src: allow only the given script, using the nonce.
* style-src: allow only the given stylesheet, using the nonce.
* connect-src: only allow fetch calls to webview resource URIs
* (this is used to load BQRS result files).
*/
return `
<html>
<head>
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src ${webview.cspSource};">
<link nonce="${nonce}" rel="stylesheet" href="${stylesheetWebviewUri}">
</head>
<body>
<div id=root>
</div>
<script nonce="${nonce}" src="${scriptWebviewUri}">
</script>
</body>
</html>`;
}
const findMatchBackground = new ThemeColor("editor.findMatchBackground");
const findRangeHighlightBackground = new ThemeColor(
"editor.findRangeHighlightBackground"
);
export const shownLocationDecoration = Window.createTextEditorDecorationType(
{
backgroundColor: findMatchBackground,
}
);
export const shownLocationLineDecoration = Window.createTextEditorDecorationType(
{
backgroundColor: findRangeHighlightBackground,
isWholeLine: true,
}
);
export async function showLocation(
loc: ResolvableLocationValue,
databaseItem: DatabaseItem
): Promise<void> {
const resolvedLocation = tryResolveLocation(loc, databaseItem);
if (resolvedLocation) {
const doc = await workspace.openTextDocument(resolvedLocation.uri);
const editorsWithDoc = Window.visibleTextEditors.filter(
(e) => e.document === doc
);
const editor =
editorsWithDoc.length > 0
? editorsWithDoc[0]
: await Window.showTextDocument(doc, ViewColumn.One);
const range = resolvedLocation.range;
// When highlighting the range, vscode's occurrence-match and bracket-match highlighting will
// trigger based on where we place the cursor/selection, and will compete for the user's attention.
// For reference:
// - Occurences are highlighted when the cursor is next to or inside a word or a whole word is selected.
// - Brackets are highlighted when the cursor is next to a bracket and there is an empty selection.
// - Multi-line selections explicitly highlight line-break characters, but multi-line decorators do not.
//
// For single-line ranges, select the whole range, mainly to disable bracket highlighting.
// For multi-line ranges, place the cursor at the beginning to avoid visual artifacts from selected line-breaks.
// Multi-line ranges are usually large enough to overshadow the noise from bracket highlighting.
const selectionEnd =
range.start.line === range.end.line ? range.end : range.start;
editor.selection = new Selection(range.start, selectionEnd);
editor.revealRange(range, TextEditorRevealType.InCenter);
editor.setDecorations(shownLocationDecoration, [range]);
editor.setDecorations(shownLocationLineDecoration, [range]);
}
}