Merge from master

This commit is contained in:
Dave Bartolomeo
2019-12-04 09:53:06 -07:00
14 changed files with 408 additions and 149 deletions

View File

@@ -87,4 +87,25 @@ jobs:
# Get the `vsix_path` and `ref_name` from the `prepare-artifacts` step above.
asset_path: ${{ steps.prepare-artifacts.outputs.vsix_path }}
asset_name: ${{ format('vscode-codeql-{0}.vsix', steps.prepare-artifacts.outputs.ref_name) }}
asset_content_type: application/zip
asset_content_type: application/zip
- name: Bump patch version
id: bump-patch-version
if: success()
run: |
cd extensions/ql-vscode
# Bump to the next patch version. Major or minor version bumps will have to be done manually.
# Record the next version number as an output of this step.
NEXT_VERSION="$(npm version patch)"
echo "::set-output name=next_version::$NEXT_VERSION"
- name: Create version bump PR
uses: peter-evans/create-pull-request@7531167f24e3914996c8d5110b5e08478ddadff9 # v1.8.0
if: success()
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: Bump version to ${{ steps.bump-patch-version.outputs.next_version }}
title: Bump version to ${{ steps.bump-patch-version.outputs.next_version }}
body: This PR was automatically generated by the GitHub Actions release workflow in this repository.
branch: ${{ format('version/bump-to-{0}', steps.bump-patch-version.outputs.next_version) }}
branch-suffix: none

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ out/
server/
node_modules/
gen/
artifacts/
# Integration test artifacts
**/.vscode-test/**

View File

@@ -197,6 +197,14 @@
"command": "codeQLQueryHistory.itemClicked",
"title": "Query History Item"
},
{
"command": "codeQLQueryResults.nextPathStep",
"title": "CodeQL: Show Next Step on Path"
},
{
"command": "codeQLQueryResults.previousPathStep",
"title": "CodeQL: Show Previous Step on Path"
},
{
"command": "codeQLTests.showOutputDifferences",
"title": "CodeQL: Show Test Output Differences"

View File

@@ -400,6 +400,13 @@ export class ReleasesApiConsumer {
Object.assign({}, this._defaultHeaders, additionalHeaders));
if (!response.ok) {
// Check for rate limiting
const rateLimitResetValue = response.headers.get("X-RateLimit-Reset");
if (response.status === 403 && rateLimitResetValue) {
const secondsToMillisecondsFactor = 1000;
const rateLimitResetDate = new Date(parseInt(rateLimitResetValue, 10) * secondsToMillisecondsFactor);
throw new GithubRateLimitedError(response.status, await response.text(), rateLimitResetDate);
}
throw new GithubApiError(response.status, await response.text());
}
return response;
@@ -673,3 +680,9 @@ export class GithubApiError extends Error {
super(`API call failed with status code ${status}, body: ${body}`);
}
}
export class GithubRateLimitedError extends GithubApiError {
constructor(public status: number, public body: string, public rateLimitResetDate: Date) {
super(status, body);
}
}

View File

@@ -4,7 +4,8 @@ import * as archiveFilesystemProvider from './archive-filesystem-provider';
import { DistributionConfigListener, QueryServerConfigListener } from './config';
import { DatabaseManager } from './databases';
import { DatabaseUI } from './databases-ui';
import { DistributionUpdateCheckResultKind, DistributionManager, FindDistributionResult, FindDistributionResultKind, GithubApiError, DEFAULT_DISTRIBUTION_VERSION_CONSTRAINT } from './distribution';
import { DistributionUpdateCheckResultKind, DistributionManager, FindDistributionResult, FindDistributionResultKind, GithubApiError,
DEFAULT_DISTRIBUTION_VERSION_CONSTRAINT, GithubRateLimitedError } from './distribution';
import * as helpers from './helpers';
import { spawnIdeServer } from './ide-server';
import { InterfaceManager, WebviewReveal } from './interface';
@@ -82,19 +83,24 @@ export async function activate(ctx: ExtensionContext): Promise<void> {
const shouldUpdateOnNextActivationKey = "shouldUpdateOnNextActivation";
registerErrorStubs(ctx, [checkForUpdatesCommand], command => () => {
Window.showErrorMessage(`Can't execute ${command}: waiting to finish loading CodeQL CLI.`);
helpers.showAndLogErrorMessage(`Can't execute ${command}: waiting to finish loading CodeQL CLI.`);
});
async function installOrUpdateDistributionWithProgressTitle(progressTitle: string, isSilentIfCannotUpdate: boolean): Promise<void> {
interface ReportingConfig {
shouldDisplayMessageWhenNoUpdates: boolean;
shouldErrorIfUpdateFails: boolean;
}
async function installOrUpdateDistributionWithProgressTitle(progressTitle: string, reportingConfig: ReportingConfig): Promise<void> {
const result = await distributionManager.checkForUpdatesToExtensionManagedDistribution();
switch (result.kind) {
case DistributionUpdateCheckResultKind.AlreadyUpToDate:
if (!isSilentIfCannotUpdate) {
if (reportingConfig.shouldDisplayMessageWhenNoUpdates) {
helpers.showAndLogInformationMessage("CodeQL CLI already up to date.");
}
break;
case DistributionUpdateCheckResultKind.InvalidDistributionLocation:
if (!isSilentIfCannotUpdate) {
if (reportingConfig.shouldDisplayMessageWhenNoUpdates) {
helpers.showAndLogErrorMessage("CodeQL CLI is installed externally so could not be updated.");
}
break;
@@ -124,34 +130,32 @@ export async function activate(ctx: ExtensionContext): Promise<void> {
}
}
async function installOrUpdateDistribution(isSilentIfCannotUpdate: boolean): Promise<void> {
async function installOrUpdateDistribution(reportingConfig: ReportingConfig): Promise<void> {
if (isInstallingOrUpdatingDistribution) {
throw new Error("Already installing or updating CodeQL CLI");
}
isInstallingOrUpdatingDistribution = true;
const codeQlInstalled = await distributionManager.getCodeQlPathWithoutVersionCheck() !== undefined;
const willUpdateCodeQl = ctx.globalState.get(shouldUpdateOnNextActivationKey);
const messageText = willUpdateCodeQl ? "Updating CodeQL CLI" :
codeQlInstalled ? "Checking for updates to CodeQL CLI" : "Installing CodeQL CLI";
try {
const codeQlInstalled = await distributionManager.getCodeQlPathWithoutVersionCheck() !== undefined;
const messageText = ctx.globalState.get(shouldUpdateOnNextActivationKey) ? "Updating CodeQL CLI" :
codeQlInstalled ? "Checking for updates to CodeQL CLI" : "Installing CodeQL CLI";
await installOrUpdateDistributionWithProgressTitle(messageText, isSilentIfCannotUpdate);
await installOrUpdateDistributionWithProgressTitle(messageText, reportingConfig);
} catch (e) {
// Don't rethrow the exception, because if the config is changed, we want to be able to retry installing
// or updating the distribution.
if (e instanceof GithubApiError && (e.status == 404 || e.status == 403 || e.status === 401)) {
const errorMessageResponse = Window.showErrorMessage("Unable to download CodeQL CLI. See " +
"https://github.com/github/vscode-codeql/blob/master/extensions/ql-vscode/README.md for more details about how " +
"to obtain CodeQL CLI.", "Edit Settings");
// We're deliberately not `await`ing this promise, just
// asynchronously letting the user follow the convenience link
// if they want to.
errorMessageResponse.then(response => {
if (response !== undefined) {
commands.executeCommand('workbench.action.openSettingsJson');
}
});
} else {
helpers.showAndLogErrorMessage("Unable to download CodeQL CLI. " + e);
const alertFunction = (codeQlInstalled && !reportingConfig.shouldErrorIfUpdateFails) ?
helpers.showAndLogWarningMessage : helpers.showAndLogErrorMessage;
const taskDescription = (willUpdateCodeQl ? "update" :
codeQlInstalled ? "check for updates to" : "install") + " CodeQL CLI";
if (e instanceof GithubRateLimitedError) {
alertFunction(`Rate limited while trying to ${taskDescription}. Please try again after ` +
`your rate limit window resets at ${e.rateLimitResetDate.toLocaleString()}.`);
} else if (e instanceof GithubApiError) {
alertFunction(`Encountered GitHub API error while trying to ${taskDescription}. ` + e);
}
alertFunction(`Unable to ${taskDescription}. ` + e);
} finally {
isInstallingOrUpdatingDistribution = false;
}
@@ -179,10 +183,8 @@ export async function activate(ctx: ExtensionContext): Promise<void> {
return result;
}
async function installOrUpdateThenTryActivate(isSilentIfCannotUpdate: boolean): Promise<void> {
if (!isInstallingOrUpdatingDistribution) {
await installOrUpdateDistribution(isSilentIfCannotUpdate);
}
async function installOrUpdateThenTryActivate(reportingConfig: ReportingConfig): Promise<void> {
await installOrUpdateDistribution(reportingConfig);
// Display the warnings even if the extension has already activated.
const distributionResult = await getDistributionDisplayingDistributionWarnings();
@@ -192,18 +194,30 @@ export async function activate(ctx: ExtensionContext): Promise<void> {
} else if (distributionResult.kind === FindDistributionResultKind.NoDistribution) {
registerErrorStubs(ctx, [checkForUpdatesCommand], command => async () => {
const installActionName = "Install CodeQL CLI";
const chosenAction = await Window.showErrorMessage(`Can't execute ${command}: missing CodeQL CLI.`, installActionName);
const chosenAction = await helpers.showAndLogErrorMessage(`Can't execute ${command}: missing CodeQL CLI.`, installActionName);
if (chosenAction === installActionName) {
installOrUpdateThenTryActivate(true);
installOrUpdateThenTryActivate({
shouldDisplayMessageWhenNoUpdates: false,
shouldErrorIfUpdateFails: true
});
}
});
}
}
ctx.subscriptions.push(distributionConfigListener.onDidChangeDistributionConfiguration(() => installOrUpdateThenTryActivate(true)));
ctx.subscriptions.push(commands.registerCommand(checkForUpdatesCommand, () => installOrUpdateThenTryActivate(false)));
ctx.subscriptions.push(distributionConfigListener.onDidChangeDistributionConfiguration(() => installOrUpdateThenTryActivate({
shouldDisplayMessageWhenNoUpdates: false,
shouldErrorIfUpdateFails: true
})));
ctx.subscriptions.push(commands.registerCommand(checkForUpdatesCommand, () => installOrUpdateThenTryActivate({
shouldDisplayMessageWhenNoUpdates: true,
shouldErrorIfUpdateFails: true
})));
await installOrUpdateThenTryActivate(true);
await installOrUpdateThenTryActivate({
shouldDisplayMessageWhenNoUpdates: false,
shouldErrorIfUpdateFails: !!ctx.globalState.get(shouldUpdateOnNextActivationKey)
});
}
async function activateWithInstalledDistribution(ctx: ExtensionContext, distributionManager: DistributionManager) {

View File

@@ -65,7 +65,15 @@ export interface SetStateMsg {
shouldKeepOldResultsWhileRendering: boolean;
};
export type IntoResultsViewMsg = ResultsUpdatingMsg | SetStateMsg;
/** Advance to the next or previous path no in the path viewer */
export interface NavigatePathMsg {
t: 'navigatePath',
/** 1 for next, -1 for previous */
direction: number;
}
export type IntoResultsViewMsg = ResultsUpdatingMsg | SetStateMsg | NavigatePathMsg;
export type FromResultsViewMsg = ViewSourceFileMsg | ToggleDiagnostics | ChangeSortMsg | ResultViewLoaded;

View File

@@ -99,6 +99,12 @@ export class InterfaceManager extends DisposableObject {
super();
this.push(this._diagnosticCollection);
this.push(vscode.window.onDidChangeTextEditorSelection(this.handleSelectionChange.bind(this)));
this.push(vscode.commands.registerCommand('codeQLQueryResults.nextPathStep', this.navigatePathStep.bind(this, 1)));
this.push(vscode.commands.registerCommand('codeQLQueryResults.previousPathStep', this.navigatePathStep.bind(this, -1)));
}
navigatePathStep(direction: number) {
this.postMessage({ t: "navigatePath", direction });
}
// Returns the webview panel, creating it if it doesn't already
@@ -236,15 +242,8 @@ export class InterfaceManager extends DisposableObject {
// user's workflow by immediately revealing the panel.
const showButton = 'View Results';
const queryName = helpers.getQueryName(info);
let queryNameForMessage: string;
if (queryName.length > 0) {
// lower case the first character
queryNameForMessage = queryName.charAt(0).toLowerCase() + queryName.substring(1);
} else {
queryNameForMessage = 'query';
}
const resultPromise = vscode.window.showInformationMessage(
`Finished running ${queryNameForMessage}.`,
`Finished running query ${(queryName.length > 0) ? `${queryName}` : ''}.`,
showButton
);
// Address this click asynchronously so we still update the

View File

@@ -30,7 +30,6 @@ export const tmpDirDisposal = {
}
};
let queryCounter = 0;
export class UserCancellationException extends Error { }
@@ -43,12 +42,14 @@ export class UserCancellationException extends Error { }
export class QueryInfo {
compiledQueryPath: string;
resultsInfo: ResultsInfo;
private static nextQueryId = 0;
/**
* Map from result set name to SortedResultSetInfo.
*/
sortedResultsInfo: Map<string, SortedResultSetInfo>;
dataset: vscode.Uri; // guarantee the existence of a well-defined dataset dir at this point
queryId: number;
constructor(
public program: messages.QlProgram,
public dbItem: DatabaseItem,
@@ -56,17 +57,17 @@ export class QueryInfo {
public quickEvalPosition?: messages.Position,
public metadata?: cli.QueryMetadata,
) {
this.compiledQueryPath = path.join(tmpDir.name, `compiledQuery${queryCounter}.qlo`);
this.queryId = QueryInfo.nextQueryId++;
this.compiledQueryPath = path.join(tmpDir.name, `compiledQuery${this.queryId}.qlo`);
this.resultsInfo = {
resultsPath: path.join(tmpDir.name, `results${queryCounter}.bqrs`),
interpretedResultsPath: path.join(tmpDir.name, `interpretedResults${queryCounter}.sarif`)
resultsPath: path.join(tmpDir.name, `results${this.queryId}.bqrs`),
interpretedResultsPath: path.join(tmpDir.name, `interpretedResults${this.queryId}.sarif`)
};
this.sortedResultsInfo = new Map();
if (dbItem.contents === undefined) {
throw new Error('Can\'t run query on invalid database.');
}
this.dataset = dbItem.contents.datasetUri;
queryCounter++;
}
async run(
@@ -160,7 +161,7 @@ export class QueryInfo {
}
const sortedResultSetInfo: SortedResultSetInfo = {
resultsPath: path.join(tmpDir.name, `sortedResults${queryCounter}-${resultSetName}.bqrs`),
resultsPath: path.join(tmpDir.name, `sortedResults${this.queryId}-${resultSetName}.bqrs`),
sortState
};

View File

@@ -0,0 +1,95 @@
import * as sarif from 'sarif';
/**
* Identifies one of the results in a result set by its index in the result list.
*/
export interface Result {
resultIndex: number;
}
/**
* Identifies one of the paths associated with a result.
*/
export interface Path extends Result {
pathIndex: number;
}
/**
* Identifies one of the nodes in a path.
*/
export interface PathNode extends Path {
pathNodeIndex: number;
}
/** Alias for `undefined` but more readable in some cases */
export const none: PathNode | undefined = undefined;
/**
* Looks up a specific result in a result set.
*/
export function getResult(sarif: sarif.Log, key: Result): sarif.Result | undefined {
if (sarif.runs.length === 0) return undefined;
if (sarif.runs[0].results === undefined) return undefined;
const results = sarif.runs[0].results;
return results[key.resultIndex];
}
/**
* Looks up a specific path in a result set.
*/
export function getPath(sarif: sarif.Log, key: Path): sarif.ThreadFlow | undefined {
let result = getResult(sarif, key);
if (result === undefined) return undefined;
let index = -1;
if (result.codeFlows === undefined) return undefined;
for (let codeFlows of result.codeFlows) {
for (let threadFlow of codeFlows.threadFlows) {
++index;
if (index == key.pathIndex)
return threadFlow;
}
}
return undefined;
}
/**
* Looks up a specific path node in a result set.
*/
export function getPathNode(sarif: sarif.Log, key: PathNode): sarif.Location | undefined {
let path = getPath(sarif, key);
if (path === undefined) return undefined;
return path.locations[key.pathNodeIndex];
}
/**
* Returns true if the two keys are both `undefined` or contain the same set of indices.
*/
export function equals(key1: PathNode | undefined, key2: PathNode | undefined): boolean {
if (key1 === key2) return true;
if (key1 === undefined || key2 === undefined) return false;
return key1.resultIndex === key2.resultIndex && key1.pathIndex === key2.pathIndex && key1.pathNodeIndex === key2.pathNodeIndex;
}
/**
* Returns true if the two keys contain the same set of indices and neither are `undefined`.
*/
export function equalsNotUndefined(key1: PathNode | undefined, key2: PathNode | undefined): boolean {
if (key1 === undefined || key2 === undefined) return false;
return key1.resultIndex === key2.resultIndex && key1.pathIndex === key2.pathIndex && key1.pathNodeIndex === key2.pathNodeIndex;
}
/**
* Returns the list of paths in the given SARIF result.
*
* Path nodes indices are relative to this flattened list.
*/
export function getAllPaths(result: sarif.Result): sarif.ThreadFlow[] {
if (result.codeFlows === undefined) return [];
let paths = [];
for (const codeFlow of result.codeFlows) {
for (const threadFlow of codeFlow.threadFlows) {
paths.push(threadFlow);
}
}
return paths;
}

View File

@@ -1,14 +1,16 @@
import * as path from 'path';
import * as React from 'react';
import * as Sarif from 'sarif';
import * as Keys from '../result-keys';
import { LocationStyle, ResolvableLocationValue } from 'semmle-bqrs';
import * as octicons from './octicons';
import { className, renderLocation, ResultTableProps, zebraStripe } from './result-table-utils';
import { PathTableResultSet } from './results';
import { className, renderLocation, ResultTableProps, zebraStripe, selectableZebraStripe, jumpToLocation } from './result-table-utils';
import { PathTableResultSet, onNavigation, NavigationEvent } from './results';
export type PathTableProps = ResultTableProps & { resultSet: PathTableResultSet };
export interface PathTableState {
expanded: { [k: string]: boolean };
selectedPathNode: undefined | Keys.PathNode;
}
interface SarifLink {
@@ -72,7 +74,8 @@ export function getPathRelativeToSourceLocationPrefix(sourceLocationPrefix: stri
export class PathTable extends React.Component<PathTableProps, PathTableState> {
constructor(props: PathTableProps) {
super(props);
this.state = { expanded: {} };
this.state = { expanded: {}, selectedPathNode: undefined };
this.handleNavigationEvent = this.handleNavigationEvent.bind(this);
}
/**
@@ -118,7 +121,8 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
if (typeof part === "string") {
result.push(<span>{part} </span>);
} else {
const renderedLocation = renderSarifLocationWithText(part.text, relatedLocationsById[part.dest]);
const renderedLocation = renderSarifLocationWithText(part.text, relatedLocationsById[part.dest],
undefined);
result.push(<span>{renderedLocation} </span>);
}
} return result;
@@ -130,75 +134,23 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
return <span title={locationHint}>{msg}</span>;
}
function parseSarifLocation(loc: Sarif.Location): ParsedSarifLocation {
const physicalLocation = loc.physicalLocation;
if (physicalLocation === undefined)
return { t: 'NoLocation', hint: 'no physical location' };
if (physicalLocation.artifactLocation === undefined)
return { t: 'NoLocation', hint: 'no artifact location' };
if (physicalLocation.artifactLocation.uri === undefined)
return { t: 'NoLocation', hint: 'artifact location has no uri' };
// This is not necessarily really an absolute uri; it could either be a
// file uri or a relative uri.
const uri = physicalLocation.artifactLocation.uri;
const fileUriRegex = /^file:/;
const effectiveLocation = uri.match(fileUriRegex) ?
decodeURIComponent(uri.replace(fileUriRegex, '')) :
getPathRelativeToSourceLocationPrefix(sourceLocationPrefix, uri);
const userVisibleFile = uri.match(fileUriRegex) ?
decodeURIComponent(uri.replace(fileUriRegex, '')) :
uri;
if (physicalLocation.region === undefined) {
// If the region property is absent, the physicalLocation object refers to the entire file.
// Source: https://docs.oasis-open.org/sarif/sarif/v2.1.0/cs01/sarif-v2.1.0-cs01.html#_Toc16012638.
// TODO: Do we get here if we provide a non-filesystem URL?
return {
t: LocationStyle.WholeFile,
file: effectiveLocation,
userVisibleFile,
};
} else {
const region = physicalLocation.region;
// We assume that the SARIF we're given always has startLine
// This is not mandated by the SARIF spec, but should be true of
// SARIF output by our own tools.
const lineStart = region.startLine!;
// These defaults are from SARIF 2.1.0 spec, section 3.30.2, "Text Regions"
// https://docs.oasis-open.org/sarif/sarif/v2.1.0/cs01/sarif-v2.1.0-cs01.html#_Ref493492556
const lineEnd = region.endLine === undefined ? lineStart : region.endLine;
const colStart = region.startColumn === undefined ? 1 : region.startColumn;
// We also assume that our tools will always supply `endColumn` field, which is
// fortunate, since the SARIF spec says that it defaults to the end of the line, whose
// length we don't know at this point in the code.
//
// It is off by one with respect to the way vscode counts columns in selections.
const colEnd = region.endColumn! - 1;
return {
t: LocationStyle.FivePart,
file: effectiveLocation,
userVisibleFile,
lineStart,
colStart,
lineEnd,
colEnd,
};
const updateSelectionCallback = (pathNodeKey: Keys.PathNode | undefined) => {
return () => {
this.setState(previousState => ({
...previousState,
selectedPathNode: pathNodeKey
}));
}
}
};
function renderSarifLocationWithText(text: string | undefined, loc: Sarif.Location): JSX.Element | undefined {
const parsedLoc = parseSarifLocation(loc);
function renderSarifLocationWithText(text: string | undefined, loc: Sarif.Location, pathNodeKey: Keys.PathNode | undefined): JSX.Element | undefined {
const parsedLoc = parseSarifLocation(loc, sourceLocationPrefix);
switch (parsedLoc.t) {
case 'NoLocation':
return renderNonLocation(text, parsedLoc.hint);
case LocationStyle.FivePart:
case LocationStyle.WholeFile:
return renderLocation(parsedLoc, text, databaseUri);
return renderLocation(parsedLoc, text, databaseUri, undefined, updateSelectionCallback(pathNodeKey));
}
return undefined;
}
@@ -207,8 +159,8 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
* Render sarif location as a link with the text being simply a
* human-readable form of the location itself.
*/
function renderSarifLocation(loc: Sarif.Location): JSX.Element | undefined {
const parsedLoc = parseSarifLocation(loc);
function renderSarifLocation(loc: Sarif.Location, pathNodeKey: Keys.PathNode | undefined): JSX.Element | undefined {
const parsedLoc = parseSarifLocation(loc, sourceLocationPrefix);
let shortLocation, longLocation: string;
switch (parsedLoc.t) {
case 'NoLocation':
@@ -216,11 +168,11 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
case LocationStyle.WholeFile:
shortLocation = `${path.basename(parsedLoc.userVisibleFile)}`;
longLocation = `${parsedLoc.userVisibleFile}`;
return renderLocation(parsedLoc, shortLocation, databaseUri, longLocation);
return renderLocation(parsedLoc, shortLocation, databaseUri, longLocation, updateSelectionCallback(pathNodeKey));
case LocationStyle.FivePart:
shortLocation = `${path.basename(parsedLoc.userVisibleFile)}:${parsedLoc.lineStart}:${parsedLoc.colStart}`;
longLocation = `${parsedLoc.userVisibleFile}`;
return renderLocation(parsedLoc, shortLocation, databaseUri, longLocation);
return renderLocation(parsedLoc, shortLocation, databaseUri, longLocation, updateSelectionCallback(pathNodeKey));
}
}
@@ -245,7 +197,7 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
const currentResultExpanded = this.state.expanded[expansionIndex];
const indicator = currentResultExpanded ? octicons.chevronDown : octicons.chevronRight;
const location = result.locations !== undefined && result.locations.length > 0 &&
renderSarifLocation(result.locations[0]);
renderSarifLocation(result.locations[0], Keys.none);
const locationCells = <td className="vscode-codeql__location-cell">{location}</td>;
if (result.codeFlows === undefined) {
@@ -260,12 +212,7 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
);
}
else {
const paths: Sarif.ThreadFlow[] = [];
for (const codeFlow of result.codeFlows) {
for (const threadFlow of codeFlow.threadFlows) {
paths.push(threadFlow);
}
}
const paths: Sarif.ThreadFlow[] = Keys.getAllPaths(result);
const indices = paths.length == 1 ?
[expansionIndex, expansionIndex + 1] : /* if there's exactly one path, auto-expand
@@ -288,7 +235,8 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
);
expansionIndex++;
paths.forEach(path => {
paths.forEach((path, pathIndex) => {
const pathKey = { resultIndex, pathIndex };
const currentPathExpanded = this.state.expanded[expansionIndex];
if (currentResultExpanded) {
const indicator = currentPathExpanded ? octicons.chevronDown : octicons.chevronRight;
@@ -305,25 +253,27 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
expansionIndex++;
if (currentResultExpanded && currentPathExpanded) {
let pathIndex = 1;
for (const step of path.locations) {
const pathNodes = path.locations;
for (let pathNodeIndex = 0; pathNodeIndex < pathNodes.length; ++pathNodeIndex) {
const pathNodeKey: Keys.PathNode = { ...pathKey, pathNodeIndex };
const step = pathNodes[pathNodeIndex];
const msg = step.location !== undefined && step.location.message !== undefined ?
renderSarifLocationWithText(step.location.message.text, step.location) :
renderSarifLocationWithText(step.location.message.text, step.location, pathNodeKey) :
'[no location]';
const additionalMsg = step.location !== undefined ?
renderSarifLocation(step.location) :
renderSarifLocation(step.location, pathNodeKey) :
'';
const stepIndex = resultIndex + pathIndex;
let isSelected = Keys.equalsNotUndefined(this.state.selectedPathNode, pathNodeKey);
const stepIndex = pathNodeIndex + 1; // Convert to 1-based
const zebraIndex = resultIndex + stepIndex;
rows.push(
<tr>
<tr className={isSelected ? 'vscode-codeql__selected-path-node' : undefined}>
<td className="vscode-codeql__icon-cell"><span className="vscode-codeql__vertical-rule"></span></td>
<td className="vscode-codeql__icon-cell"><span className="vscode-codeql__vertical-rule"></span></td>
<td {...zebraStripe(stepIndex, 'vscode-codeql__path-index-cell')}>{pathIndex}</td>
<td {...zebraStripe(stepIndex)}>{msg} </td>
<td {...zebraStripe(stepIndex, 'vscode-codeql__location-cell')}>{additionalMsg}</td>
<td {...selectableZebraStripe(isSelected, zebraIndex, 'vscode-codeql__path-index-cell')}>{stepIndex}</td>
<td {...selectableZebraStripe(isSelected, zebraIndex)}>{msg} </td>
<td {...selectableZebraStripe(isSelected, zebraIndex, 'vscode-codeql__location-cell')}>{additionalMsg}</td>
</tr>);
pathIndex++;
}
}
});
@@ -341,4 +291,96 @@ export class PathTable extends React.Component<PathTableProps, PathTableState> {
<tbody>{rows}</tbody>
</table>;
}
private handleNavigationEvent(event: NavigationEvent) {
this.setState(prevState => {
let { selectedPathNode } = prevState;
if (selectedPathNode === undefined) return prevState;
let path = Keys.getPath(this.props.resultSet.sarif, selectedPathNode);
if (path === undefined) return prevState;
let nextIndex = selectedPathNode.pathNodeIndex + event.direction;
if (nextIndex < 0 || nextIndex >= path.locations.length) return prevState;
let sarifLoc = path.locations[nextIndex].location;
if (sarifLoc === undefined) return prevState;
let loc = parseSarifLocation(sarifLoc, this.props.resultSet.sourceLocationPrefix);
if (loc.t === 'NoLocation') return prevState;
jumpToLocation(loc, this.props.databaseUri);
let newSelection = { ...selectedPathNode, pathNodeIndex: nextIndex };
return { ...prevState, selectedPathNode: newSelection };
});
}
componentDidMount() {
onNavigation.addListener(this.handleNavigationEvent);
}
componentWillUnmount() {
onNavigation.removeListener(this.handleNavigationEvent);
}
}
function parseSarifLocation(loc: Sarif.Location, sourceLocationPrefix: string): ParsedSarifLocation {
const physicalLocation = loc.physicalLocation;
if (physicalLocation === undefined)
return { t: 'NoLocation', hint: 'no physical location' };
if (physicalLocation.artifactLocation === undefined)
return { t: 'NoLocation', hint: 'no artifact location' };
if (physicalLocation.artifactLocation.uri === undefined)
return { t: 'NoLocation', hint: 'artifact location has no uri' };
// This is not necessarily really an absolute uri; it could either be a
// file uri or a relative uri.
const uri = physicalLocation.artifactLocation.uri;
const fileUriRegex = /^file:/;
const effectiveLocation = uri.match(fileUriRegex) ?
decodeURIComponent(uri.replace(fileUriRegex, '')) :
getPathRelativeToSourceLocationPrefix(sourceLocationPrefix, uri);
const userVisibleFile = uri.match(fileUriRegex) ?
decodeURIComponent(uri.replace(fileUriRegex, '')) :
uri;
if (physicalLocation.region === undefined) {
// If the region property is absent, the physicalLocation object refers to the entire file.
// Source: https://docs.oasis-open.org/sarif/sarif/v2.1.0/cs01/sarif-v2.1.0-cs01.html#_Toc16012638.
// TODO: Do we get here if we provide a non-filesystem URL?
return {
t: LocationStyle.WholeFile,
file: effectiveLocation,
userVisibleFile,
};
} else {
const region = physicalLocation.region;
// We assume that the SARIF we're given always has startLine
// This is not mandated by the SARIF spec, but should be true of
// SARIF output by our own tools.
const lineStart = region.startLine!;
// These defaults are from SARIF 2.1.0 spec, section 3.30.2, "Text Regions"
// https://docs.oasis-open.org/sarif/sarif/v2.1.0/cs01/sarif-v2.1.0-cs01.html#_Ref493492556
const lineEnd = region.endLine === undefined ? lineStart : region.endLine;
const colStart = region.startColumn === undefined ? 1 : region.startColumn;
// We also assume that our tools will always supply `endColumn` field, which is
// fortunate, since the SARIF spec says that it defaults to the end of the line, whose
// length we don't know at this point in the code.
//
// It is off by one with respect to the way vscode counts columns in selections.
const colEnd = region.endColumn! - 1;
return {
t: LocationStyle.FivePart,
file: effectiveLocation,
userVisibleFile,
lineStart,
colStart,
lineEnd,
colEnd,
};
}
}

View File

@@ -0,0 +1,25 @@
export type EventHandler<T> = (event: T) => void;
/**
* A set of listeners for events of type `T`.
*/
export class EventHandlers<T> {
private handlers: EventHandler<T>[] = [];
public addListener(handler: EventHandler<T>) {
this.handlers.push(handler);
}
public removeListener(handler: EventHandler<T>) {
let index = this.handlers.indexOf(handler);
if (index !== -1) {
this.handlers.splice(index, 1);
}
}
public fire(event: T) {
for (let handler of this.handlers) {
handler(event);
}
}
}

View File

@@ -16,27 +16,34 @@ export const toggleDiagnosticsClassName = `${className}-toggle-diagnostics`;
export const evenRowClassName = 'vscode-codeql__result-table-row--even';
export const oddRowClassName = 'vscode-codeql__result-table-row--odd';
export const pathRowClassName = 'vscode-codeql__result-table-row--path';
export const selectedRowClassName = 'vscode-codeql__result-table-row--selected';
export function jumpToLocationHandler(
loc: ResolvableLocationValue,
databaseUri: string
databaseUri: string,
callback?: () => void
): (e: React.MouseEvent) => void {
return (e) => {
vscode.postMessage({
t: 'viewSourceFile',
loc,
databaseUri
});
jumpToLocation(loc, databaseUri);
e.preventDefault();
e.stopPropagation();
if (callback) callback();
};
}
export function jumpToLocation(loc: ResolvableLocationValue, databaseUri: string) {
vscode.postMessage({
t: 'viewSourceFile',
loc,
databaseUri
});
}
/**
* Render a location as a link which when clicked displays the original location.
*/
export function renderLocation(loc: LocationValue | undefined, label: string | undefined,
databaseUri: string, title?: string): JSX.Element {
databaseUri: string, title?: string, callback?: () => void): JSX.Element {
// If the label was empty, use a placeholder instead, so the link is still clickable.
let displayLabel = label;
@@ -51,7 +58,7 @@ export function renderLocation(loc: LocationValue | undefined, label: string | u
return <a href="#"
className="vscode-codeql__result-table-location-link"
title={title}
onClick={jumpToLocationHandler(resolvableLoc, databaseUri)}>{displayLabel}</a>;
onClick={jumpToLocationHandler(resolvableLoc, databaseUri, callback)}>{displayLabel}</a>;
} else {
return <span title={title}>{displayLabel}</span>;
}
@@ -63,5 +70,15 @@ export function renderLocation(loc: LocationValue | undefined, label: string | u
* Returns the attributes for a zebra-striped table row at position `index`.
*/
export function zebraStripe(index: number, ...otherClasses: string[]): { className: string } {
return { className: [(index % 2) ? oddRowClassName : evenRowClassName, otherClasses].join(' ') };
return { className: [(index % 2) ? oddRowClassName : evenRowClassName, ...otherClasses].join(' ') };
}
/**
* Returns the attributes for a zebra-striped table row at position `index`,
* with highlighting if `isSelected` is true.
*/
export function selectableZebraStripe(isSelected: boolean, index: number, ...otherClasses: string[]): { className: string } {
return isSelected
? { className: [selectedRowClassName, ...otherClasses].join(' ') }
: zebraStripe(index, ...otherClasses)
}

View File

@@ -3,8 +3,9 @@ import * as Rdom from 'react-dom';
import * as bqrs from 'semmle-bqrs';
import { ElementBase, LocationValue, PrimitiveColumnValue, PrimitiveTypeKind, ResultSetSchema, tryGetResolvableLocation } from 'semmle-bqrs';
import { assertNever } from '../helpers-pure';
import { DatabaseInfo, FromResultsViewMsg, Interpretation, IntoResultsViewMsg, SortedResultSetInfo, SortState } from '../interface-types';
import { DatabaseInfo, FromResultsViewMsg, Interpretation, IntoResultsViewMsg, SortedResultSetInfo, SortState, NavigatePathMsg } from '../interface-types';
import { ResultTables } from './result-tables';
import { EventHandlers as EventHandlerList } from './event-handler-list';
/**
* results.tsx
@@ -156,6 +157,13 @@ interface ResultsViewState {
isExpectingResultsUpdate: boolean;
}
export type NavigationEvent = NavigatePathMsg;
/**
* Event handlers to be notified of navigation events coming from outside the webview.
*/
export const onNavigation = new EventHandlerList<NavigationEvent>();
/**
* A minimal state container for displaying results.
*/
@@ -192,6 +200,9 @@ class App extends React.Component<{}, ResultsViewState> {
isExpectingResultsUpdate: true
});
break;
case 'navigatePath':
onNavigation.fire(msg);
break;
default:
assertNever(msg);
}

View File

@@ -87,6 +87,10 @@ select {
background-color: var(--vscode-textBlockQuote-background);
}
.vscode-codeql__result-table-row--selected {
background-color: var(--vscode-editor-findMatchBackground);
}
td.vscode-codeql__icon-cell {
text-align: center;
position: relative;