Skip to content

Commit d8c3410

Browse files
Merge pull request #2490 from github/robertbrignull/resolve-queries-manual
Add manual discovery of queries and query packs
2 parents e83ad36 + d2b69b1 commit d8c3410

File tree

14 files changed

+1310
-385
lines changed

14 files changed

+1310
-385
lines changed

extensions/ql-vscode/src/codeql-cli/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,7 @@ export class CodeQLCliServer implements Disposable {
718718
async resolveLibraryPath(
719719
workspaces: string[],
720720
queryPath: string,
721+
silent = false,
721722
): Promise<QuerySetup> {
722723
const subcommandArgs = [
723724
"--query",
@@ -728,6 +729,7 @@ export class CodeQLCliServer implements Disposable {
728729
["resolve", "library-path"],
729730
subcommandArgs,
730731
"Resolving library paths",
732+
{ silent },
731733
);
732734
}
733735

extensions/ql-vscode/src/common/discovery.ts

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Logger } from "./logging";
77
* files. This class automatically prevents more than one discovery operation from running at the
88
* same time.
99
*/
10-
export abstract class Discovery<T> extends DisposableObject {
10+
export abstract class Discovery extends DisposableObject {
1111
private restartWhenFinished = false;
1212
private currentDiscoveryPromise: Promise<void> | undefined;
1313

@@ -64,14 +64,12 @@ export abstract class Discovery<T> extends DisposableObject {
6464
* discovery.
6565
*/
6666
private async launchDiscovery(): Promise<void> {
67-
let results: T | undefined;
6867
try {
69-
results = await this.discover();
68+
await this.discover();
7069
} catch (err) {
7170
void this.logger.log(
7271
`${this.name} failed. Reason: ${getErrorMessage(err)}`,
7372
);
74-
results = undefined;
7573
}
7674

7775
if (this.restartWhenFinished) {
@@ -82,24 +80,11 @@ export abstract class Discovery<T> extends DisposableObject {
8280
// succeeded or failed.
8381
this.restartWhenFinished = false;
8482
await this.launchDiscovery();
85-
} else {
86-
// If the discovery was successful, then update any listeners with the results.
87-
if (results !== undefined) {
88-
this.update(results);
89-
}
9083
}
9184
}
9285

9386
/**
9487
* Overridden by the derived class to spawn the actual discovery operation, returning the results.
9588
*/
96-
protected abstract discover(): Promise<T>;
97-
98-
/**
99-
* Overridden by the derived class to atomically update the `Discovery` object with the results of
100-
* the discovery operation, and to notify any listeners that the discovery results may have
101-
* changed.
102-
* @param results The discovery results returned by the `discover` function.
103-
*/
104-
protected abstract update(results: T): void;
89+
protected abstract discover(): Promise<void>;
10590
}

extensions/ql-vscode/src/common/query-language.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@ export const PACKS_BY_QUERY_LANGUAGE = {
2525
[QueryLanguage.Ruby]: ["codeql/ruby-queries"],
2626
};
2727

28-
export const dbSchemeToLanguage = {
29-
"semmlecode.javascript.dbscheme": "javascript",
30-
"semmlecode.cpp.dbscheme": "cpp",
31-
"semmlecode.dbscheme": "java",
32-
"semmlecode.python.dbscheme": "python",
33-
"semmlecode.csharp.dbscheme": "csharp",
34-
"go.dbscheme": "go",
35-
"ruby.dbscheme": "ruby",
36-
"swift.dbscheme": "swift",
28+
export const dbSchemeToLanguage: Record<string, QueryLanguage> = {
29+
"semmlecode.javascript.dbscheme": QueryLanguage.Javascript,
30+
"semmlecode.cpp.dbscheme": QueryLanguage.Cpp,
31+
"semmlecode.dbscheme": QueryLanguage.Java,
32+
"semmlecode.python.dbscheme": QueryLanguage.Python,
33+
"semmlecode.csharp.dbscheme": QueryLanguage.CSharp,
34+
"go.dbscheme": QueryLanguage.Go,
35+
"ruby.dbscheme": QueryLanguage.Ruby,
36+
"swift.dbscheme": QueryLanguage.Swift,
3737
};
3838

3939
export function isQueryLanguage(language: string): language is QueryLanguage {
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import { Discovery } from "../discovery";
2+
import {
3+
Event,
4+
EventEmitter,
5+
RelativePattern,
6+
Uri,
7+
WorkspaceFoldersChangeEvent,
8+
workspace,
9+
} from "vscode";
10+
import { MultiFileSystemWatcher } from "./multi-file-system-watcher";
11+
import { AppEventEmitter } from "../events";
12+
import { extLogger } from "..";
13+
import { lstat } from "fs-extra";
14+
import { containsPath, isIOError } from "../../pure/files";
15+
import {
16+
getOnDiskWorkspaceFolders,
17+
getOnDiskWorkspaceFoldersObjects,
18+
} from "./workspace-folders";
19+
20+
interface PathData {
21+
path: string;
22+
}
23+
24+
/**
25+
* Discovers and watches for changes to all files matching a given filter
26+
* contained in the workspace. Also allows computing extra data about each
27+
* file path, and only recomputing the data when the file changes.
28+
*
29+
* Scans the whole workspace on startup, and then watches for changes to files
30+
* to do the minimum work to keep up with changes.
31+
*
32+
* Can configure which changes it watches for, which files are considered
33+
* relevant, and what extra data to compute for each file.
34+
*/
35+
export abstract class FilePathDiscovery<T extends PathData> extends Discovery {
36+
/** The set of known paths and associated data that we are tracking */
37+
private pathData: T[] = [];
38+
39+
/** Event that fires whenever the contents of `pathData` changes */
40+
private readonly onDidChangePathDataEmitter: AppEventEmitter<void>;
41+
42+
/**
43+
* The set of file paths that may have changed on disk since the last time
44+
* refresh was run. Whenever a watcher reports some change to a file we add
45+
* it to this set, and then during the next refresh we will process all
46+
* file paths from this set and update our internal state to match whatever
47+
* we find on disk (i.e. the file exists, doesn't exist, computed data has
48+
* changed).
49+
*/
50+
private readonly changedFilePaths = new Set<string>();
51+
52+
/**
53+
* Watches for changes to files and directories in all workspace folders.
54+
*/
55+
private readonly watcher: MultiFileSystemWatcher = this.push(
56+
new MultiFileSystemWatcher(),
57+
);
58+
59+
/**
60+
* @param name Name of the discovery operation, for logging purposes.
61+
* @param fileWatchPattern Passed to `vscode.RelativePattern` to determine the files to watch for changes to.
62+
*/
63+
constructor(name: string, private readonly fileWatchPattern: string) {
64+
super(name, extLogger);
65+
66+
this.onDidChangePathDataEmitter = this.push(new EventEmitter<void>());
67+
this.push(
68+
workspace.onDidChangeWorkspaceFolders(
69+
this.workspaceFoldersChanged.bind(this),
70+
),
71+
);
72+
this.push(this.watcher.onDidChange(this.fileChanged.bind(this)));
73+
}
74+
75+
protected getPathData(): ReadonlyArray<Readonly<T>> {
76+
return this.pathData;
77+
}
78+
79+
protected get onDidChangePathData(): Event<void> {
80+
return this.onDidChangePathDataEmitter.event;
81+
}
82+
83+
/**
84+
* Compute any extra data to be stored regarding the given path.
85+
*/
86+
protected abstract getDataForPath(path: string): Promise<T>;
87+
88+
/**
89+
* Is the given path relevant to this discovery operation?
90+
*/
91+
protected abstract pathIsRelevant(path: string): boolean;
92+
93+
/**
94+
* Should the given new data overwrite the existing data we have stored?
95+
*/
96+
protected abstract shouldOverwriteExistingData(
97+
newData: T,
98+
existingData: T,
99+
): boolean;
100+
101+
/**
102+
* Update the data for every path by calling `getDataForPath`.
103+
*/
104+
protected async recomputeAllData() {
105+
this.pathData = await Promise.all(
106+
this.pathData.map((p) => this.getDataForPath(p.path)),
107+
);
108+
this.onDidChangePathDataEmitter.fire();
109+
}
110+
111+
/**
112+
* Do the initial scan of the entire workspace and set up watchers for future changes.
113+
*/
114+
public async initialRefresh() {
115+
getOnDiskWorkspaceFolders().forEach((workspaceFolder) => {
116+
this.changedFilePaths.add(workspaceFolder);
117+
});
118+
119+
this.updateWatchers();
120+
return this.refresh();
121+
}
122+
123+
private workspaceFoldersChanged(event: WorkspaceFoldersChangeEvent) {
124+
event.added.forEach((workspaceFolder) => {
125+
this.changedFilePaths.add(workspaceFolder.uri.fsPath);
126+
});
127+
event.removed.forEach((workspaceFolder) => {
128+
this.changedFilePaths.add(workspaceFolder.uri.fsPath);
129+
});
130+
131+
this.updateWatchers();
132+
void this.refresh();
133+
}
134+
135+
private updateWatchers() {
136+
this.watcher.clear();
137+
for (const workspaceFolder of getOnDiskWorkspaceFoldersObjects()) {
138+
// Watch for changes to individual files
139+
this.watcher.addWatch(
140+
new RelativePattern(workspaceFolder, this.fileWatchPattern),
141+
);
142+
// need to explicitly watch for changes to directories themselves.
143+
this.watcher.addWatch(new RelativePattern(workspaceFolder, "**/"));
144+
}
145+
}
146+
147+
private fileChanged(uri: Uri) {
148+
this.changedFilePaths.add(uri.fsPath);
149+
void this.refresh();
150+
}
151+
152+
protected async discover() {
153+
let pathsUpdated = false;
154+
for (const path of this.changedFilePaths) {
155+
this.changedFilePaths.delete(path);
156+
if (await this.handleChangedPath(path)) {
157+
pathsUpdated = true;
158+
}
159+
}
160+
161+
if (pathsUpdated) {
162+
this.onDidChangePathDataEmitter.fire();
163+
}
164+
}
165+
166+
private async handleChangedPath(path: string): Promise<boolean> {
167+
try {
168+
// If the path is not in the workspace then we don't want to be
169+
// tracking or displaying it, so treat it as if it doesn't exist.
170+
if (!this.pathIsInWorkspace(path)) {
171+
return this.handleRemovedPath(path);
172+
}
173+
174+
if ((await lstat(path)).isDirectory()) {
175+
return await this.handleChangedDirectory(path);
176+
} else {
177+
return this.handleChangedFile(path);
178+
}
179+
} catch (e) {
180+
if (isIOError(e) && e.code === "ENOENT") {
181+
return this.handleRemovedPath(path);
182+
}
183+
throw e;
184+
}
185+
}
186+
187+
private pathIsInWorkspace(path: string): boolean {
188+
return getOnDiskWorkspaceFolders().some((workspaceFolder) =>
189+
containsPath(workspaceFolder, path),
190+
);
191+
}
192+
193+
private handleRemovedPath(path: string): boolean {
194+
const oldLength = this.pathData.length;
195+
this.pathData = this.pathData.filter(
196+
(existingPathData) => !containsPath(path, existingPathData.path),
197+
);
198+
return this.pathData.length !== oldLength;
199+
}
200+
201+
private async handleChangedDirectory(path: string): Promise<boolean> {
202+
const newPaths = await workspace.findFiles(
203+
new RelativePattern(path, this.fileWatchPattern),
204+
);
205+
206+
let pathsUpdated = false;
207+
for (const path of newPaths) {
208+
if (await this.addOrUpdatePath(path.fsPath)) {
209+
pathsUpdated = true;
210+
}
211+
}
212+
return pathsUpdated;
213+
}
214+
215+
private async handleChangedFile(path: string): Promise<boolean> {
216+
if (this.pathIsRelevant(path)) {
217+
return await this.addOrUpdatePath(path);
218+
} else {
219+
return false;
220+
}
221+
}
222+
223+
private async addOrUpdatePath(path: string): Promise<boolean> {
224+
const data = await this.getDataForPath(path);
225+
const existingPathDataIndex = this.pathData.findIndex(
226+
(existingPathData) => existingPathData.path === path,
227+
);
228+
if (existingPathDataIndex !== -1) {
229+
if (
230+
this.shouldOverwriteExistingData(
231+
data,
232+
this.pathData[existingPathDataIndex],
233+
)
234+
) {
235+
this.pathData.splice(existingPathDataIndex, 1, data);
236+
return true;
237+
} else {
238+
return false;
239+
}
240+
} else {
241+
this.pathData.push(data);
242+
return true;
243+
}
244+
}
245+
}

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export async function getDirectoryNamesInsidePath(
5151
return dirNames;
5252
}
5353

54-
function normalizePath(path: string): string {
54+
export function normalizePath(path: string): string {
5555
// On Windows, "C:/", "C:\", and "c:/" are all equivalent. We need
5656
// to normalize the paths to ensure they all get resolved to the
5757
// same format. On Windows, we also need to do the comparison
@@ -107,3 +107,17 @@ export async function* walkDirectory(
107107
}
108108
}
109109
}
110+
111+
/**
112+
* Error thrown from methods from the `fs` module.
113+
*
114+
* In practice, any error matching this is likely an instance of `NodeJS.ErrnoException`.
115+
* If desired in the future, we could model more fields or use `NodeJS.ErrnoException` directly.
116+
*/
117+
export interface IOError {
118+
readonly code: string;
119+
}
120+
121+
export function isIOError(e: any): e is IOError {
122+
return e.code !== undefined && typeof e.code === "string";
123+
}

extensions/ql-vscode/src/queries-panel/queries-module.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { isCanary, showQueriesPanel } from "../config";
55
import { DisposableObject } from "../pure/disposable-object";
66
import { QueriesPanel } from "./queries-panel";
77
import { QueryDiscovery } from "./query-discovery";
8+
import { QueryPackDiscovery } from "./query-pack-discovery";
89

910
export class QueriesModule extends DisposableObject {
1011
private constructor(readonly app: App) {
@@ -19,9 +20,16 @@ export class QueriesModule extends DisposableObject {
1920
}
2021
void extLogger.log("Initializing queries panel.");
2122

22-
const queryDiscovery = new QueryDiscovery(app.environment, cliServer);
23+
const queryPackDiscovery = new QueryPackDiscovery(cliServer);
24+
this.push(queryPackDiscovery);
25+
void queryPackDiscovery.initialRefresh();
26+
27+
const queryDiscovery = new QueryDiscovery(
28+
app.environment,
29+
queryPackDiscovery,
30+
);
2331
this.push(queryDiscovery);
24-
void queryDiscovery.refresh();
32+
void queryDiscovery.initialRefresh();
2533

2634
const queriesPanel = new QueriesPanel(queryDiscovery);
2735
this.push(queriesPanel);

0 commit comments

Comments
 (0)