More work on the graph viewer

The viewer is largely implemented now with the following features and
limitations:

1. Any query with `@kind graph` will be opened as a graph
2. Queries that are `@kind graph` and
   `@tags ide-contextual-queries/print-cfg` will be used in the
   `CodeQL: View CFG` context command. This will be visible
   similar to how the AST viewer works. If there is not exactly
   1 such query for a given language, then the extension will throw
   an error.
3. Unfortunately, the cg viewer assumes that the entire file will
   be added to the graph, so often this will be too big, That leads to
   the following limitation:
4. There is no size checking on the graph. Graphs that are too big will
   crash vscode.
5. Lastly, there is a small bug in how the `@id` is interpreted. Any
   `@id` with a `/` in it will place the `.dot` in a location that
   can't be found by vscode. So, just don't name your queries with any
   `/`.

This feature is only available in canary mode.
This commit is contained in:
Andrew Eisenberg
2022-01-31 20:13:01 -08:00
parent 580832ea7b
commit d1362bf44f
12 changed files with 124 additions and 93 deletions

View File

@@ -745,7 +745,7 @@
{
"command": "codeQL.viewCfg",
"group": "9_qlCommands",
"when": "resourceScheme == codeql-zip-archive"
"when": "resourceScheme == codeql-zip-archive && config.codeQL.canary"
},
{
"command": "codeQL.runQueries",
@@ -810,7 +810,7 @@
},
{
"command": "codeQL.viewCfg",
"when": "resourceScheme == codeql-zip-archive"
"when": "resourceScheme == codeql-zip-archive && config.codeQL.canary"
},
{
"command": "codeQLDatabases.setCurrentDatabase",
@@ -958,6 +958,10 @@
"command": "codeQL.viewAst",
"when": "resourceScheme == codeql-zip-archive"
},
{
"command": "codeQL.viewCfg",
"when": "resourceScheme == codeql-zip-archive && config.codeQL.canary"
},
{
"command": "codeQL.quickEval",
"when": "editorLangId == ql"

View File

@@ -0,0 +1,16 @@
/**
* The jszip and d3 libraries are designed to work in both the browser and
* node. Consequently their typings files refer to both node
* types like `Buffer` (which don't exist in the browser), and browser
* types like `Blob` (which don't exist in node). Instead of sticking
* all of `dom` in `compilerOptions.lib`, it suffices just to put in a
* stub definition of the affected types so that compilation
* succeeds.
*/
declare type Blob = string;
declare type RequestInit = Record<string, unknown>;
declare type ElementTagNameMap = any;
declare type NodeListOf<T> = Record<string, T>;
declare type Node = Record<string, unknown>;
declare type XMLDocument = Record<string, unknown>;

View File

@@ -1,11 +0,0 @@
/**
* The npm library jszip is designed to work in both the browser and
* node. Consequently its typings @types/jszip refers to both node
* types like `Buffer` (which don't exist in the browser), and browser
* types like `Blob` (which don't exist in node). Instead of sticking
* all of `dom` in `compilerOptions.lib`, it suffices just to put in a
* stub definition of the type `Blob` here so that compilation
* succeeds.
*/
//declare type Blob = string; // TODO: Check this

View File

@@ -1061,11 +1061,11 @@ class SplitBuffer {
/**
* A version of startsWith that isn't overriden by a broken version of ms-python.
*
*
* The definition comes from
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith
* which is CC0/public domain
*
*
* See https://github.com/github/vscode-codeql/issues/802 for more context as to why we need it.
*/
private static startsWith(s: string, searchString: string, position: number): boolean {

View File

@@ -258,14 +258,14 @@ export class TemplatePrintCfgProvider {
const qlpack = await qlpackOfDatabase(this.cli, db);
if (!qlpack) {
throw new Error('Can\'t infer qlpack from database source archive');
throw new Error('Can\'t infer qlpack from database source archive.');
}
const queries = await resolveQueries(this.cli, qlpack, KeyType.PrintCfgQuery);
if (queries.length > 1) {
throw new Error('Found multiple Print CFG queries. Can\'t continue');
throw new Error(`Found multiple Print CFG queries. Can't continue. Make sure there is exacly one query with the tag ${KeyType.PrintCfgQuery}`);
}
if (queries.length === 0) {
throw new Error('Did not find any Print CFG queries. Can\'t continue');
throw new Error(`Did not find any Print CFG queries. Can't continue. Make sure there is exacly one query with the tag ${KeyType.PrintCfgQuery}`);
}
const queryUri = Uri.file(queries[0]);

View File

@@ -982,26 +982,6 @@ async function activateWithInstalledDistribution(
})
);
ctx.subscriptions.push(
commandRunnerWithProgress(
'codeQL.viewCfg',
async (
progress: ProgressCallback,
token: CancellationToken
) => {
const res = await new TemplatePrintCfgProvider(cliServer, dbm)
.provideCfgUri(window.activeTextEditor?.document);
if (res) {
await compileAndRunQuery(false, res[0], progress, token, undefined);
}
},
{
title: 'Calculate CFG',
cancellable: true
}
)
);
void logger.log('Starting language server.');
ctx.subscriptions.push(client.start());
@@ -1018,7 +998,8 @@ async function activateWithInstalledDistribution(
);
const astViewer = new AstViewer();
const templateProvider = new TemplatePrintAstProvider(cliServer, qs, dbm);
const printAstTemplateProvider = new TemplatePrintAstProvider(cliServer, qs, dbm);
const cfgTemplateProvider = new TemplatePrintCfgProvider(cliServer, dbm);
ctx.subscriptions.push(astViewer);
ctx.subscriptions.push(commandRunnerWithProgress('codeQL.viewAst', async (
@@ -1026,7 +1007,7 @@ async function activateWithInstalledDistribution(
token: CancellationToken,
selectedFile: Uri
) => {
const ast = await templateProvider.provideAst(
const ast = await printAstTemplateProvider.provideAst(
progress,
token,
selectedFile ?? window.activeTextEditor?.document.uri,
@@ -1039,6 +1020,26 @@ async function activateWithInstalledDistribution(
title: 'Calculate AST'
}));
ctx.subscriptions.push(
commandRunnerWithProgress(
'codeQL.viewCfg',
async (
progress: ProgressCallback,
token: CancellationToken
) => {
const res = await
cfgTemplateProvider.provideCfgUri(window.activeTextEditor?.document);
if (res) {
await compileAndRunQuery(false, res[0], progress, token, undefined);
}
},
{
title: 'Calculating Control Flow Graph',
cancellable: true
}
)
);
await commands.executeCommand('codeQLDatabases.removeOrphanedDatabases');
void logger.log('Successfully finished extension initialization.');

View File

@@ -162,9 +162,9 @@ export class InterfaceManager extends DisposableObject {
this._diagnosticCollection.clear();
if (this.isShowingPanel()) {
void this.postMessage({
t: 'untoggleShowProblems'
});
}
t: 'untoggleShowProblems'
});
}
}
})
);
@@ -347,8 +347,8 @@ export class InterfaceManager extends DisposableObject {
break;
default:
assertNever(msg);
}
} catch (e) {
}
} catch (e) {
void showAndLogErrorMessage(e.message, {
fullMessage: e.stack
});
@@ -608,15 +608,14 @@ export class InterfaceManager extends DisposableObject {
sourceInfo: cli.SourceInfo | undefined,
sourceLocationPrefix: string,
sortState: InterpretedResultsSortState | undefined
): Promise<Interpretation | undefined> {
): Promise<Interpretation | undefined> {
if (!resultsPaths) {
void this.logger.log('No results path. Cannot display interpreted results.');
return undefined;
}
let data;
let numTotalResults;
if (metadata?.kind === 'graph')
{
if (metadata?.kind === GRAPH_TABLE_NAME) {
data = await interpretGraphResults(
this.cliServer,
metadata,
@@ -624,9 +623,7 @@ export class InterfaceManager extends DisposableObject {
sourceInfo
);
numTotalResults = data.dot.length;
}
else
{
} else {
const sarif = await interpretResultsSarif(
this.cliServer,
metadata,
@@ -635,7 +632,7 @@ export class InterfaceManager extends DisposableObject {
);
sarif.runs.forEach(run => {
if (run.results !== undefined) {
if (run.results) {
sortInterpretedResults(run.results, sortState);
}
});
@@ -644,9 +641,9 @@ export class InterfaceManager extends DisposableObject {
data = sarif;
numTotalResults = (() => {
if (sarif.runs.length === 0) return 0;
if (sarif.runs[0].results === undefined) return 0;
return sarif.runs[0].results.length;
return sarif.runs?.[0]?.results
? sarif.runs[0].results.length
: 0;
})();
}
@@ -686,7 +683,10 @@ export class InterfaceManager extends DisposableObject {
return {
...interp,
data: { ...interp.data, runs: [getPageOfRun(interp.data.runs[0])] },
data: {
...interp.data,
runs: [getPageOfRun(interp.data.runs[0])]
}
};
}

View File

@@ -281,7 +281,7 @@ export class QueryHistoryManager extends DisposableObject {
} else {
this.treeDataProvider.setCurrentItem(ev.selection[0]);
}
this.updateCompareWith(ev.selection);
this.updateCompareWith([...ev.selection]);
})
);
@@ -851,14 +851,14 @@ the file in the file explorer and dragging it into the workspace.`
private determineSelection(
singleItem: FullQueryInfo,
multiSelect: FullQueryInfo[]
): { finalSingleItem: FullQueryInfo; finalMultiSelect: FullQueryInfo[] } {
): { readonly finalSingleItem: FullQueryInfo; readonly finalMultiSelect: FullQueryInfo[] } {
if (!singleItem && !multiSelect?.[0]) {
const selection = this.treeView.selection;
const current = this.treeDataProvider.getCurrent();
if (selection?.length) {
return {
finalSingleItem: selection[0],
finalMultiSelect: selection
finalMultiSelect: [...selection]
};
} else if (current) {
return {

View File

@@ -165,6 +165,25 @@ export async function interpretResultsSarif(
return { ...res, t: 'SarifInterpretationData' };
}
/**
* Call cli command to interpret graph results.
*/
export async function interpretGraphResults(
cli: cli.CodeQLCliServer,
metadata: QueryMetadata | undefined,
resultsPaths: ResultsPaths,
sourceInfo?: cli.SourceInfo
): Promise<GraphInterpretationData> {
const { resultsPath, interpretedResultsPath } = resultsPaths;
if (await fs.pathExists(interpretedResultsPath)) {
const dot = await cli.readDotFiles(interpretedResultsPath);
return { dot, t: 'GraphInterpretationData' };
}
const dot = await cli.interpretBqrsGraph(ensureMetadataIsComplete(metadata), resultsPath, interpretedResultsPath, sourceInfo);
return { dot, t: 'GraphInterpretationData' };
}
export function ensureMetadataIsComplete(metadata: QueryMetadata | undefined) {
if (metadata === undefined) {
throw new Error('Can\'t interpret results without query metadata');
@@ -380,22 +399,3 @@ export class FullQueryInfo {
});
}
}
/**
* Call cli command to interpret graph results.
*/
export async function interpretGraphResults(
server: cli.CodeQLCliServer,
metadata: QueryMetadata | undefined,
resultsPaths: ResultsPaths,
sourceInfo?: cli.SourceInfo
): Promise<GraphInterpretationData> {
const { resultsPath, interpretedResultsPath } = resultsPaths;
if (await fs.pathExists(interpretedResultsPath)) {
const dot = await server.readDotFiles(interpretedResultsPath);
return { dot, t: 'GraphInterpretationData' };
}
const dot = await server.interpretBqrsGraph(ensureMetadataIsComplete(metadata), resultsPath, interpretedResultsPath, sourceInfo);
return { dot, t: 'GraphInterpretationData' };
}

View File

@@ -7,15 +7,26 @@ import { jumpToLocation } from './result-table-utils';
import { tryGetLocationFromString } from '../pure/bqrs-utils';
export type GraphProps = ResultTableProps & { resultSet: InterpretedResultSet<GraphInterpretationData> };
const className = 'vscode-codeql__result-tables-graph';
const graphClassName = 'vscode-codeql__result-tables-graph';
const graphId = 'graph-results';
export class Graph extends React.Component<GraphProps> {
constructor(props: GraphProps) {
super(props);
}
public render = (): JSX.Element => {
return <div id={className} className={className} />;
const { resultSet, offset } = this.props;
const graphData = resultSet.interpretation?.data?.dot[offset];
if (!graphData) {
return <>
<div className={graphClassName}>Graph is not available.</div>
</>;
}
return <>
<div id={graphId} className={graphClassName}><span>Rendering graph...</span></div>
</>;
};
public componentDidMount = () => {
@@ -27,7 +38,13 @@ export class Graph extends React.Component<GraphProps> {
};
private renderGraph = () => {
const { databaseUri, resultSet } = this.props;
const { databaseUri, resultSet, offset } = this.props;
const graphData = resultSet.interpretation?.data?.dot[offset];
if (!graphData) {
return;
}
const options = {
fit: true,
fade: false,
@@ -35,13 +52,18 @@ export class Graph extends React.Component<GraphProps> {
zoom: true,
};
const element = document.querySelector(`.${className}`);
const color = element ? getComputedStyle(element).color : 'black';
const backgroundColor = element ? getComputedStyle(element).backgroundColor : 'transparent';
const borderColor = element ? getComputedStyle(element).borderColor : 'black';
const element = document.querySelector(`#${graphId}`);
if (!element) {
return;
}
element.firstChild?.remove();
const color = getComputedStyle(element).color;
const backgroundColor = getComputedStyle(element).backgroundColor;
const borderColor = getComputedStyle(element).borderColor;
let firstPolygon = true;
graphviz(`#${className}`)
graphviz(`#${graphId}`)
.options(options)
.attributer(function(d) {
if (d.tag == 'a') {
@@ -62,13 +84,14 @@ export class Graph extends React.Component<GraphProps> {
// There is no proper way to identify the element containing the graph (which we
// don't want a border around), as it is just has tag 'polygon'. Instead we assume
// that the first polygon we see is that element
if (d.tag != 'polygon' || !firstPolygon)
if (d.tag != 'polygon' || !firstPolygon) {
d.attributes.stroke = borderColor;
else
} else {
firstPolygon = false;
}
}
})
.renderDot(resultSet.interpretation.data.dot[this.props.offset]);
.renderDot(graphData);
};
}

View File

@@ -65,8 +65,6 @@ describe('queryResolver', () => {
it('should throw an error when there are no queries found', async () => {
mockCli.resolveQueriesInSuite.returns([]);
// TODO: Figure out why chai-as-promised isn't failing the test on an
// unhandled rejection.
try {
await module.resolveQueries(mockCli, { dbschemePack: 'my-qlpack' }, KeyType.DefinitionQuery);
// should reject

View File

@@ -6,7 +6,7 @@
"module": "commonjs",
"target": "es2017",
"outDir": "out",
"lib": ["ES2020", "dom"], // No idea what I am doing; needed to avoid `Cannot find name 'RequestInit'` error
"lib": ["ES2020"],
"moduleResolution": "node",
"sourceMap": true,
"rootDir": "src",