Add unit tests for interface-utils.ts

Also, some moving around of functions and whitespace changes.
This commit is contained in:
Andrew Eisenberg
2020-06-09 08:06:27 -07:00
parent fceea64a08
commit b803a80d39
13 changed files with 507 additions and 323 deletions

View File

@@ -18,10 +18,7 @@ import {
import { Logger } from "../logging";
import { CodeQLCliServer } from "../cli";
import { DatabaseManager } from "../databases";
import {
getHtmlForWebview,
jumpToLocation,
} from "../webview-utils";
import { getHtmlForWebview, jumpToLocation } from "../interface-utils";
import { adaptSchema, adaptBqrs, RawResultSet } from "../adapt";
import { BQRSInfo } from "../bqrs-cli-types";
import resultsDiff from "./resultsDiff";
@@ -190,7 +187,8 @@ export class CompareInterfaceManager extends DisposableObject {
const commonResultSetNames = fromSchemaNames.filter((name) =>
toSchemaNames.includes(name)
);
const currentResultSetName = selectedResultSetName || commonResultSetNames[0];
const currentResultSetName =
selectedResultSetName || commonResultSetNames[0];
const fromResultSet = await this.getResultSet(
fromSchemas,
currentResultSetName,
@@ -213,7 +211,11 @@ export class CompareInterfaceManager extends DisposableObject {
if (!this.comparePair?.from || !this.comparePair.to) {
return;
}
await this.showResults(this.comparePair.from, this.comparePair.to, newResultSetName);
await this.showResults(
this.comparePair.from,
this.comparePair.to,
newResultSetName
);
}
private async getResultSet(

View File

@@ -22,7 +22,7 @@ import * as helpers from './helpers';
import { assertNever } from './helpers-pure';
import { spawnIdeServer } from './ide-server';
import { InterfaceManager } from './interface';
import { WebviewReveal } from './webview-utils';
import { WebviewReveal } from './interface-utils';
import { ideServerLogger, logger, queryServerLogger } from './logging';
import { QueryHistoryManager } from './query-history';
import { CompletedQuery } from './query-results';

View File

@@ -1,6 +1,27 @@
import * as sarif from 'sarif';
import { ResolvableLocationValue, ColumnSchema } from 'semmle-bqrs';
import { ResultRow, ParsedResultSets } from './adapt';
import {
ResolvableLocationValue,
ColumnSchema,
ResultSetSchema,
} from "semmle-bqrs";
import { ResultRow, ParsedResultSets, RawResultSet } from './adapt';
/**
* This module contains types and code that are shared between
* the webview and the extension.
*/
export const SELECT_TABLE_NAME = "#select";
export const ALERTS_TABLE_NAME = "alerts";
export type RawTableResultSet = { t: "RawResultSet" } & RawResultSet;
export type PathTableResultSet = {
t: "SarifResultSet";
readonly schema: ResultSetSchema;
name: string;
} & Interpretation;
export type ResultSet = RawTableResultSet | PathTableResultSet;
/**
* Only ever show this many results per run in interpreted results.
@@ -228,3 +249,26 @@ export type QueryCompareResult = {
from: ResultRow[];
to: ResultRow[];
};
/**
* Extract the name of the default result. Prefer returning
* 'alerts', or '#select'. Otherwise return the first in the list.
*
* Note that this is the only function in this module. It must be
* placed here since it is shared across the webview boundary.
*
* We should consider moving to a separate module to ensure this
* one is types only.
*
* @param resultSetNames
*/
export function getDefaultResultSetName(
resultSetNames: readonly string[]
): string {
// Choose first available result set from the array
return [
ALERTS_TABLE_NAME,
SELECT_TABLE_NAME,
resultSetNames[0],
].filter((resultSetName) => resultSetNames.includes(resultSetName))[0];
}

View File

@@ -1,32 +1,233 @@
import { RawResultSet } from './adapt';
import { ResultSetSchema } from 'semmle-bqrs';
import { Interpretation } from './interface-types';
import * as crypto from "crypto";
import {
Uri,
Location,
Range,
WebviewPanel,
Webview,
workspace,
window as Window,
ViewColumn,
Selection,
TextEditorRevealType,
ThemeColor,
} from "vscode";
import {
FivePartLocation,
LocationStyle,
LocationValue,
tryGetResolvableLocation,
WholeFileLocation,
ResolvableLocationValue,
} from "semmle-bqrs";
import { DatabaseItem, DatabaseManager } from "./databases";
import { ViewSourceFileMsg } from "./interface-types";
import { Logger } from "./logging";
export const SELECT_TABLE_NAME = "#select";
export const ALERTS_TABLE_NAME = "alerts";
/**
* This module contains functions and types that are sharedd between
* interface.ts and compare-interface.ts.
*/
export type RawTableResultSet = { t: "RawResultSet" } & RawResultSet;
export type PathTableResultSet = {
t: "SarifResultSet";
readonly schema: ResultSetSchema;
name: string;
} & Interpretation;
export type ResultSet = RawTableResultSet | PathTableResultSet;
export function getDefaultResultSet(resultSets: readonly ResultSet[]): string {
return getDefaultResultSetName(
resultSets.map((resultSet) => resultSet.schema.name)
);
/** Gets a nonce string created with 128 bits of entropy. */
export function getNonce(): string {
return crypto.randomBytes(16).toString("base64");
}
export function getDefaultResultSetName(
resultSetNames: readonly string[]
/**
* 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 {
// Choose first available result set from the array
return [
ALERTS_TABLE_NAME,
SELECT_TABLE_NAME,
resultSetNames[0],
].filter((resultSetName) => resultSetNames.includes(resultSetName))[0];
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.
*/
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.
*/
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>`;
}
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]);
}
}
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 jumpToLocation(
msg: ViewSourceFileMsg,
databaseManager: DatabaseManager,
logger: Logger
) {
const databaseItem = databaseManager.findDatabaseItem(
Uri.parse(msg.databaseUri)
);
if (databaseItem !== undefined) {
try {
await showLocation(msg.loc, databaseItem);
} catch (e) {
if (e instanceof Error) {
if (e.message.match(/File not found/)) {
Window.showErrorMessage(
`Original file of this result is not in the database's source archive.`
);
} else {
logger.log(`Unable to handleMsgFromView: ${e.message}`);
}
} else {
logger.log(`Unable to handleMsgFromView: ${e}`);
}
}
}
}

View File

@@ -49,8 +49,8 @@ import {
shownLocationDecoration,
shownLocationLineDecoration,
jumpToLocation,
} from "./webview-utils";
import { getDefaultResultSetName } from "./interface-utils";
} from "./interface-utils";
import { getDefaultResultSetName } from "./interface-types";
/**
* interface.ts

View File

@@ -6,7 +6,7 @@ import { LocationStyle } from 'semmle-bqrs';
import * as octicons from './octicons';
import { className, renderLocation, ResultTableProps, zebraStripe, selectableZebraStripe, jumpToLocation, nextSortDirection } from './result-table-utils';
import { onNavigation, NavigationEvent } from './results';
import { PathTableResultSet } from '../interface-utils';
import { PathTableResultSet } from '../interface-types';
import { parseSarifPlainTextMessage, parseSarifLocation } from '../sarif-utils';
import { InterpretedResultsSortColumn, SortDirection, InterpretedResultsSortState } from '../interface-types';
import { vscode } from './vscode-api';

View File

@@ -1,9 +1,10 @@
import * as React from "react";
import { ResultTableProps, className } from "./result-table-utils";
import { RAW_RESULTS_LIMIT, RawResultsSortState } from "../interface-types";
import { RawTableResultSet } from "../interface-utils";
import { RawTableResultSet } from "../interface-types";
import RawTableHeader from "./RawTableHeader";
import RawTableRow from "./RawTableRow";
import { ResultRow } from "../adapt";
export type RawTableProps = ResultTableProps & {
resultSet: RawTableResultSet;
@@ -26,7 +27,7 @@ export class RawTable extends React.Component<RawTableProps, {}> {
dataRows = dataRows.slice(0, RAW_RESULTS_LIMIT);
}
const tableRows = dataRows.map((row, rowIndex) =>
const tableRows = dataRows.map((row: ResultRow, rowIndex: number) =>
<RawTableRow
key={rowIndex}
rowIndex={rowIndex}

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import { LocationValue, ResolvableLocationValue, tryGetResolvableLocation } from 'semmle-bqrs';
import { RawResultsSortState, QueryMetadata, SortDirection } from '../interface-types';
import { assertNever } from '../helpers-pure';
import { ResultSet } from '../interface-utils';
import { ResultSet } from '../interface-types';
import { vscode } from './vscode-api';
export interface ResultTableProps {

View File

@@ -1,12 +1,24 @@
import * as React from 'react';
import { DatabaseInfo, Interpretation, RawResultsSortState, QueryMetadata, ResultsPaths, InterpretedResultsSortState, RAW_RESULTS_PAGE_SIZE } from '../interface-types';
import {
DatabaseInfo,
Interpretation,
RawResultsSortState,
QueryMetadata,
ResultsPaths,
InterpretedResultsSortState,
RAW_RESULTS_PAGE_SIZE,
ResultSet,
ALERTS_TABLE_NAME,
SELECT_TABLE_NAME,
getDefaultResultSetName,
} from "../interface-types";
import { PathTable } from './alert-table';
import { RawTable } from './raw-results-table';
import { ResultTableProps, tableSelectionHeaderClassName, toggleDiagnosticsClassName, alertExtrasClassName } from './result-table-utils';
import { ParsedResultSets, ExtensionParsedResultSets } from '../adapt';
import { ResultSet, ALERTS_TABLE_NAME, SELECT_TABLE_NAME, getDefaultResultSet } from '../interface-utils';
import { vscode } from './vscode-api';
/**
* Properties for the `ResultTables` component.
*/
@@ -274,6 +286,14 @@ class ResultTable extends React.Component<ResultTableProps, {}> {
{...this.props} resultSet={resultSet} />;
case 'SarifResultSet': return <PathTable
{...this.props} resultSet={resultSet} />;
default:
throw new Error('Invalid type');
}
}
}
function getDefaultResultSet(resultSets: readonly ResultSet[]): string {
return getDefaultResultSetName(
resultSets.map((resultSet) => resultSet.schema.name)
);
}

View File

@@ -25,7 +25,7 @@ import {
ResultRow,
ParsedResultSets,
} from "../adapt";
import { ResultSet } from "../interface-utils";
import { ResultSet } from "../interface-types";
import { vscode } from "./vscode-api";
/**

View File

@@ -0,0 +1,195 @@
import { expect } from "chai";
import * as vscode from "vscode";
import * as path from "path";
import * as sinon from "sinon";
import * as tmp from "tmp";
import { window, ViewColumn, Uri } from "vscode";
import {
fileUriToWebviewUri,
webviewUriToFileUri,
tryResolveLocation,
} from "../../interface-utils";
import { getDefaultResultSetName } from "../../interface-types";
import { LocationStyle } from "semmle-bqrs";
import { DatabaseItem } from "../../databases";
describe("interface-utils", () => {
describe("webview uri conversion", function () {
const fileSuffix = ".bqrs";
function setupWebview(filePrefix: string) {
const tmpFile = tmp.fileSync({
prefix: `uri_test_${filePrefix}_`,
postfix: fileSuffix,
keep: false,
});
const fileUriOnDisk = Uri.file(tmpFile.name);
const panel = window.createWebviewPanel(
"test panel",
"test panel",
ViewColumn.Beside,
{
enableScripts: false,
localResourceRoots: [fileUriOnDisk],
}
);
after(function () {
panel.dispose();
tmpFile.removeCallback();
});
// CSP allowing nothing, to prevent warnings.
const html = `<html><head><meta http-equiv="Content-Security-Policy" content="default-src 'none';"></head></html>`;
panel.webview.html = html;
return {
fileUriOnDisk,
panel,
};
}
it("should correctly round trip from filesystem to webview and back", function () {
const { fileUriOnDisk, panel } = setupWebview("");
const webviewUri = fileUriToWebviewUri(panel, fileUriOnDisk);
const reconstructedFileUri = webviewUriToFileUri(webviewUri);
expect(reconstructedFileUri.toString(true)).to.equal(
fileUriOnDisk.toString(true)
);
});
it("does not double-encode # in URIs", function () {
const { fileUriOnDisk, panel } = setupWebview("#");
const webviewUri = fileUriToWebviewUri(panel, fileUriOnDisk);
const parsedUri = Uri.parse(webviewUri);
expect(path.basename(parsedUri.path, fileSuffix)).to.equal(
path.basename(fileUriOnDisk.path, fileSuffix)
);
});
});
describe("getDefaultResultSetName", () => {
it("should get the default name", () => {
expect(getDefaultResultSetName(["a", "b", "#select", "alerts"])).to.equal(
"alerts"
);
expect(getDefaultResultSetName(["a", "b", "#select"])).to.equal(
"#select"
);
expect(getDefaultResultSetName(["a", "b"])).to.equal("a");
expect(getDefaultResultSetName([])).to.be.undefined;
});
});
describe("resolveWholeFileLocation", () => {
it("should resolve a whole file location", () => {
const mockDatabaseItem: DatabaseItem = ({
resolveSourceFile: sinon.stub().returns(vscode.Uri.parse("abc")),
} as unknown) as DatabaseItem;
expect(
tryResolveLocation(
{
t: LocationStyle.WholeFile,
file: "hucairz",
},
mockDatabaseItem
)
).to.deep.equal(
new vscode.Location(
vscode.Uri.parse("abc"),
new vscode.Range(0, 0, 0, 0)
)
);
});
it("should resolve a five-part location", () => {
const mockDatabaseItem: DatabaseItem = ({
resolveSourceFile: sinon.stub().returns(vscode.Uri.parse("abc")),
} as unknown) as DatabaseItem;
expect(
tryResolveLocation(
{
t: LocationStyle.FivePart,
colStart: 1,
colEnd: 3,
lineStart: 4,
lineEnd: 5,
file: "hucairz",
},
mockDatabaseItem
)
).to.deep.equal(
new vscode.Location(
vscode.Uri.parse("abc"),
new vscode.Range(new vscode.Position(4, 3), new vscode.Position(3, 0))
)
);
expect(mockDatabaseItem.resolveSourceFile).to.have.been.calledOnceWith(
"hucairz"
);
});
it("should resolve a string location for whole file", () => {
const mockDatabaseItem: DatabaseItem = ({
resolveSourceFile: sinon.stub().returns(vscode.Uri.parse("abc")),
} as unknown) as DatabaseItem;
expect(
tryResolveLocation(
{
t: LocationStyle.String,
loc: "file://hucairz:0:0:0:0"
},
mockDatabaseItem
)
).to.deep.equal(
new vscode.Location(
vscode.Uri.parse("abc"),
new vscode.Range(0, 0, 0, 0)
)
);
expect(mockDatabaseItem.resolveSourceFile).to.have.been.calledOnceWith(
"hucairz"
);
});
it("should resolve a string location for five-part location", () => {
const mockDatabaseItem: DatabaseItem = ({
resolveSourceFile: sinon.stub().returns(vscode.Uri.parse("abc")),
} as unknown) as DatabaseItem;
expect(
tryResolveLocation(
{
t: LocationStyle.String,
loc: "file://hucairz:5:4:3:2"
},
mockDatabaseItem
)
).to.deep.equal(
new vscode.Location(
vscode.Uri.parse("abc"),
new vscode.Range(new vscode.Position(4, 3), new vscode.Position(2, 2))
)
);
expect(mockDatabaseItem.resolveSourceFile).to.have.been.calledOnceWith(
"hucairz"
);
});
it("should resolve a string location for invalid string", () => {
const mockDatabaseItem: DatabaseItem = ({
resolveSourceFile: sinon.stub().returns(vscode.Uri.parse("abc")),
} as unknown) as DatabaseItem;
expect(
tryResolveLocation(
{
t: LocationStyle.String,
loc: "file://hucairz:x:y:z:a"
},
mockDatabaseItem
)
).to.be.undefined;
});
});
});

View File

@@ -1,51 +0,0 @@
import { expect } from "chai";
import * as path from "path";
import * as tmp from "tmp";
import { window, ViewColumn, Uri } from "vscode";
import { fileUriToWebviewUri, webviewUriToFileUri } from '../../webview-utils';
describe('webview uri conversion', function() {
const fileSuffix = '.bqrs';
function setupWebview(filePrefix: string) {
const tmpFile = tmp.fileSync({ prefix: `uri_test_${filePrefix}_`, postfix: fileSuffix, keep: false });
const fileUriOnDisk = Uri.file(tmpFile.name);
const panel = window.createWebviewPanel(
'test panel',
'test panel',
ViewColumn.Beside,
{
enableScripts: false,
localResourceRoots: [
fileUriOnDisk
]
}
);
after(function() {
panel.dispose();
tmpFile.removeCallback();
});
// CSP allowing nothing, to prevent warnings.
const html = '<html><head><meta http-equiv="Content-Security-Policy" content="default-src \'none\';"></head></html>';
panel.webview.html = html;
return {
fileUriOnDisk,
panel
};
}
it('should correctly round trip from filesystem to webview and back', function() {
const { fileUriOnDisk, panel } = setupWebview('');
const webviewUri = fileUriToWebviewUri(panel, fileUriOnDisk);
const reconstructedFileUri = webviewUriToFileUri(webviewUri);
expect(reconstructedFileUri.toString(true)).to.equal(fileUriOnDisk.toString(true));
});
it('does not double-encode # in URIs', function() {
const { fileUriOnDisk, panel } = setupWebview('#');
const webviewUri = fileUriToWebviewUri(panel, fileUriOnDisk);
const parsedUri = Uri.parse(webviewUri);
expect(path.basename(parsedUri.path, fileSuffix)).to.equal(path.basename(fileUriOnDisk.path, fileSuffix));
});
});

View File

@@ -1,228 +0,0 @@
import * as crypto from "crypto";
import {
Uri,
Location,
Range,
WebviewPanel,
Webview,
workspace,
window as Window,
ViewColumn,
Selection,
TextEditorRevealType,
ThemeColor,
} from "vscode";
import {
FivePartLocation,
LocationStyle,
LocationValue,
tryGetResolvableLocation,
WholeFileLocation,
ResolvableLocationValue,
} from "semmle-bqrs";
import { DatabaseItem, DatabaseManager } from "./databases";
import { ViewSourceFileMsg } from "./interface-types";
import { Logger } from "./logging";
/** 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>`;
}
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]);
}
}
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 jumpToLocation(
msg: ViewSourceFileMsg,
databaseManager: DatabaseManager,
logger: Logger
) {
const databaseItem = databaseManager.findDatabaseItem(
Uri.parse(msg.databaseUri)
);
if (databaseItem !== undefined) {
try {
await showLocation(msg.loc, databaseItem);
} catch (e) {
if (e instanceof Error) {
if (e.message.match(/File not found/)) {
Window.showErrorMessage(
`Original file of this result is not in the database's source archive.`
);
} else {
logger.log(`Unable to handleMsgFromView: ${e.message}`);
}
} else {
logger.log(`Unable to handleMsgFromView: ${e}`);
}
}
}
}