Merge pull request #175 from asgerf/asgerf/path-result-nav

Add commands for navigating the steps on a path
This commit is contained in:
jcreedcmu
2019-11-25 13:57:03 -05:00
committed by GitHub
9 changed files with 316 additions and 100 deletions

View File

@@ -170,6 +170,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"
}
],
"menus": {

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

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;