Skip to content

Commit c3a21b9

Browse files
author
Dave Bartolomeo
authored
Merge pull request #1430 from github/dbartol/goto-ql
Initial implementation of sourcemap-based jump-to-QL command
2 parents 0c654c4 + 6b9f73e commit c3a21b9

File tree

13 files changed

+3436
-5
lines changed

13 files changed

+3436
-5
lines changed

extensions/ql-vscode/package.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
},
3636
"activationEvents": [
3737
"onLanguage:ql",
38+
"onLanguage:ql-summary",
3839
"onView:codeQLDatabases",
3940
"onView:codeQLQueryHistory",
4041
"onView:codeQLAstViewer",
@@ -111,6 +112,12 @@
111112
"extensions": [
112113
".qhelp"
113114
]
115+
},
116+
{
117+
"id": "ql-summary",
118+
"filenames": [
119+
"evaluator-log.summary"
120+
]
114121
}
115122
],
116123
"grammars": [
@@ -621,6 +628,11 @@
621628
"light": "media/light/clear-all.svg",
622629
"dark": "media/dark/clear-all.svg"
623630
}
631+
},
632+
{
633+
"command": "codeQL.gotoQL",
634+
"title": "CodeQL: Go to QL Code",
635+
"enablement": "codeql.hasQLSource"
624636
}
625637
],
626638
"menus": {
@@ -1115,6 +1127,10 @@
11151127
{
11161128
"command": "codeQL.previewQueryHelp",
11171129
"when": "resourceExtname == .qhelp && isWorkspaceTrusted"
1130+
},
1131+
{
1132+
"command": "codeQL.gotoQL",
1133+
"when": "editorLangId == ql-summary && config.codeQL.canary"
11181134
}
11191135
]
11201136
},

extensions/ql-vscode/src/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,7 @@ export class CodeQLCliServer implements Disposable {
683683
const subcommandArgs = [
684684
'--format=text',
685685
`--end-summary=${endSummaryPath}`,
686+
'--sourcemap',
686687
inputPath,
687688
outputPath
688689
];

extensions/ql-vscode/src/extension.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ import { HistoryItemLabelProvider } from './history-item-label-provider';
9999
import { exportRemoteQueryResults } from './remote-queries/export-results';
100100
import { RemoteQuery } from './remote-queries/remote-query';
101101
import { EvalLogViewer } from './eval-log-viewer';
102+
import { SummaryLanguageSupport } from './log-insights/summary-language-support';
102103

103104
/**
104105
* extension.ts
@@ -1051,6 +1052,8 @@ async function activateWithInstalledDistribution(
10511052
})
10521053
);
10531054

1055+
ctx.subscriptions.push(new SummaryLanguageSupport());
1056+
10541057
void logger.log('Starting language server.');
10551058
ctx.subscriptions.push(client.start());
10561059

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import * as fs from 'fs-extra';
2+
import { RawSourceMap, SourceMapConsumer } from 'source-map';
3+
import { commands, Position, Selection, TextDocument, TextEditor, TextEditorRevealType, TextEditorSelectionChangeEvent, ViewColumn, window, workspace } from 'vscode';
4+
import { DisposableObject } from '../pure/disposable-object';
5+
import { commandRunner } from '../commandRunner';
6+
import { logger } from '../logging';
7+
import { getErrorMessage } from '../pure/helpers-pure';
8+
9+
/** A `Position` within a specified file on disk. */
10+
interface PositionInFile {
11+
filePath: string;
12+
position: Position;
13+
}
14+
15+
/**
16+
* Opens the specified source location in a text editor.
17+
* @param position The position (including file path) to show.
18+
*/
19+
async function showSourceLocation(position: PositionInFile): Promise<void> {
20+
const document = await workspace.openTextDocument(position.filePath);
21+
const editor = await window.showTextDocument(document, ViewColumn.Active);
22+
editor.selection = new Selection(position.position, position.position);
23+
editor.revealRange(editor.selection, TextEditorRevealType.InCenterIfOutsideViewport);
24+
}
25+
26+
/**
27+
* Simple language support for human-readable evaluator log summaries.
28+
*
29+
* This class implements the `codeQL.gotoQL` command, which jumps from RA code to the corresponding
30+
* QL code that generated it. It also tracks the current selection and active editor to enable and
31+
* disable that command based on whether there is a QL mapping for the current selection.
32+
*/
33+
export class SummaryLanguageSupport extends DisposableObject {
34+
/**
35+
* The last `TextDocument` (with language `ql-summary`) for which we tried to find a sourcemap, or
36+
* `undefined` if we have not seen such a document yet.
37+
*/
38+
private lastDocument : TextDocument | undefined = undefined;
39+
/**
40+
* The sourcemap for `lastDocument`, or `undefined` if there was no such sourcemap or document.
41+
*/
42+
private sourceMap : SourceMapConsumer | undefined = undefined;
43+
44+
constructor() {
45+
super();
46+
47+
this.push(window.onDidChangeActiveTextEditor(this.handleDidChangeActiveTextEditor));
48+
this.push(window.onDidChangeTextEditorSelection(this.handleDidChangeTextEditorSelection));
49+
this.push(workspace.onDidCloseTextDocument(this.handleDidCloseTextDocument));
50+
51+
this.push(commandRunner('codeQL.gotoQL', this.handleGotoQL));
52+
}
53+
54+
/**
55+
* Gets the location of the QL code that generated the RA at the current selection in the active
56+
* editor, or `undefined` if there is no mapping.
57+
*/
58+
private async getQLSourceLocation(): Promise<PositionInFile | undefined> {
59+
const editor = window.activeTextEditor;
60+
if (editor === undefined) {
61+
return undefined;
62+
}
63+
64+
const document = editor.document;
65+
if (document.languageId !== 'ql-summary') {
66+
return undefined;
67+
}
68+
69+
if (document.uri.scheme !== 'file') {
70+
return undefined;
71+
}
72+
73+
if (this.lastDocument !== document) {
74+
this.clearCache();
75+
76+
const mapPath = document.uri.fsPath + '.map';
77+
78+
try {
79+
const sourceMapText = await fs.readFile(mapPath, 'utf-8');
80+
const rawMap: RawSourceMap = JSON.parse(sourceMapText);
81+
this.sourceMap = await new SourceMapConsumer(rawMap);
82+
} catch (e: unknown) {
83+
// Error reading sourcemap. Pretend there was no sourcemap.
84+
void logger.log(`Error reading sourcemap file '${mapPath}': ${getErrorMessage(e)}`);
85+
this.sourceMap = undefined;
86+
}
87+
this.lastDocument = document;
88+
}
89+
90+
if (this.sourceMap === undefined) {
91+
return undefined;
92+
}
93+
94+
const qlPosition = this.sourceMap.originalPositionFor({
95+
line: editor.selection.start.line + 1,
96+
column: editor.selection.start.character,
97+
bias: SourceMapConsumer.GREATEST_LOWER_BOUND
98+
});
99+
100+
if ((qlPosition.source === null) || (qlPosition.line === null)) {
101+
// No position found.
102+
return undefined;
103+
}
104+
const line = qlPosition.line - 1; // In `source-map`, lines are 1-based...
105+
const column = qlPosition.column ?? 0; // ...but columns are 0-based :(
106+
107+
return {
108+
filePath: qlPosition.source,
109+
position: new Position(line, column)
110+
};
111+
}
112+
113+
/**
114+
* Clears the cached sourcemap and its corresponding `TextDocument`.
115+
*/
116+
private clearCache(): void {
117+
if (this.sourceMap !== undefined) {
118+
this.sourceMap.destroy();
119+
this.sourceMap = undefined;
120+
this.lastDocument = undefined;
121+
}
122+
}
123+
124+
/**
125+
* Updates the `codeql.hasQLSource` context variable based on the current selection. This variable
126+
* controls whether or not the `codeQL.gotoQL` command is enabled.
127+
*/
128+
private async updateContext(): Promise<void> {
129+
const position = await this.getQLSourceLocation();
130+
131+
await commands.executeCommand('setContext', 'codeql.hasQLSource', position !== undefined);
132+
}
133+
134+
handleDidChangeActiveTextEditor = async (_editor: TextEditor | undefined): Promise<void> => {
135+
await this.updateContext();
136+
}
137+
138+
handleDidChangeTextEditorSelection = async (_e: TextEditorSelectionChangeEvent): Promise<void> => {
139+
await this.updateContext();
140+
}
141+
142+
handleDidCloseTextDocument = (document: TextDocument): void => {
143+
if (this.lastDocument === document) {
144+
this.clearCache();
145+
}
146+
}
147+
148+
handleGotoQL = async (): Promise<void> => {
149+
const position = await this.getQLSourceLocation();
150+
if (position !== undefined) {
151+
await showSourceLocation(position);
152+
}
153+
};
154+
}

extensions/ql-vscode/src/pure/log-summary-parser.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
// TODO(angelapwen): Only load in necessary information and
2-
// location in bytes for this log to save memory.
2+
// location in bytes for this log to save memory.
33
export interface EvalLogData {
44
predicateName: string;
55
millis: number;
66
resultSize: number;
7-
// Key: pipeline identifier; Value: array of pipeline steps
7+
// Key: pipeline identifier; Value: array of pipeline steps
88
ra: Record<string, string[]>;
99
}
1010

extensions/ql-vscode/src/pure/messages.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,10 @@ export interface CompilationOptions {
155155
* get reported anyway. Useful for universal compilation options.
156156
*/
157157
computeDefaultStrings: boolean;
158+
/**
159+
* Emit debug information in compiled query.
160+
*/
161+
emitDebugInfo: boolean;
158162
}
159163

160164
/**

extensions/ql-vscode/src/run-queries.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,8 @@ export class QueryEvaluationInfo {
241241
localChecking: false,
242242
noComputeGetUrl: false,
243243
noComputeToString: false,
244-
computeDefaultStrings: true
244+
computeDefaultStrings: true,
245+
emitDebugInfo: true
245246
},
246247
extraOptions: {
247248
timeoutSecs: qs.config.timeoutSecs

extensions/ql-vscode/src/vscode-tests/cli-integration/query.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,8 @@ describe('using the query server', function() {
151151
localChecking: false,
152152
noComputeGetUrl: false,
153153
noComputeToString: false,
154-
computeDefaultStrings: true
154+
computeDefaultStrings: true,
155+
emitDebugInfo: true
155156
},
156157
queryToCheck: qlProgram,
157158
resultPath: COMPILED_QUERY_PATH,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { fail } from 'assert';
2+
import { commands, Selection, window, workspace } from 'vscode';
3+
import * as path from 'path';
4+
import * as assert from 'assert';
5+
import { expect } from 'chai';
6+
import { tmpDir } from '../../helpers';
7+
import * as fs from 'fs-extra';
8+
9+
/**
10+
* Integration tests for queries
11+
*/
12+
describe('SourceMap', function() {
13+
this.timeout(20000);
14+
15+
it('should jump to QL code', async () => {
16+
try {
17+
const root = workspace.workspaceFolders![0].uri.fsPath;
18+
const srcFiles = {
19+
summary: path.join(root, 'log-summary', 'evaluator-log.summary'),
20+
summaryMap: path.join(root, 'log-summary', 'evaluator-log.summary.map')
21+
};
22+
// We need to modify the source map so that its paths point to the actual location of the
23+
// workspace root on this machine. We'll copy the summary and its source map to a temp
24+
// directory, modify the source map their, and open that summary.
25+
const tempFiles = await copyFilesToTempDirectory(srcFiles);
26+
27+
// The checked-in sourcemap has placeholders of the form `${root}`, which we need to replace
28+
// with the actual root directory.
29+
const mapText = await fs.readFile(tempFiles.summaryMap, 'utf-8');
30+
// Always use forward slashes, since they work everywhere.
31+
const slashRoot = root.replaceAll('\\', '/');
32+
const newMapText = mapText.replaceAll('${root}', slashRoot);
33+
await fs.writeFile(tempFiles.summaryMap, newMapText);
34+
35+
const summaryDocument = await workspace.openTextDocument(tempFiles.summary);
36+
assert(summaryDocument.languageId === 'ql-summary');
37+
const summaryEditor = await window.showTextDocument(summaryDocument);
38+
summaryEditor.selection = new Selection(356, 10, 356, 10);
39+
await commands.executeCommand('codeQL.gotoQL');
40+
41+
const newEditor = window.activeTextEditor;
42+
expect(newEditor).to.be.not.undefined;
43+
const newDocument = newEditor!.document;
44+
expect(path.basename(newDocument.fileName)).to.equal('Namespace.qll');
45+
const newSelection = newEditor!.selection;
46+
expect(newSelection.start.line).to.equal(60);
47+
expect(newSelection.start.character).to.equal(2);
48+
} catch (e) {
49+
console.error('Test Failed');
50+
fail(e as Error);
51+
}
52+
});
53+
54+
async function copyFilesToTempDirectory<T extends Record<string, string>>(files: T): Promise<T> {
55+
const tempDir = path.join(tmpDir.name, 'log-summary');
56+
await fs.ensureDir(tempDir);
57+
const result: Record<string, string> = {};
58+
for (const [key, srcPath] of Object.entries(files)) {
59+
const destPath = path.join(tempDir, path.basename(srcPath));
60+
await fs.copy(srcPath, destPath);
61+
result[key] = destPath;
62+
}
63+
64+
return result as T;
65+
}
66+
});

extensions/ql-vscode/src/vscode-tests/no-workspace/run-queries.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@ describe('run-queries', () => {
171171
localChecking: false,
172172
noComputeGetUrl: false,
173173
noComputeToString: false,
174-
computeDefaultStrings: true
174+
computeDefaultStrings: true,
175+
emitDebugInfo: true,
175176
},
176177
extraOptions: {
177178
timeoutSecs: 5

0 commit comments

Comments
 (0)