Merge branch 'main' into koesie10/auto-name-extension-pack

This commit is contained in:
Koen Vlaswinkel
2023-06-20 15:12:15 +02:00
committed by GitHub
27 changed files with 1475 additions and 438 deletions

View File

@@ -718,6 +718,7 @@ export class CodeQLCliServer implements Disposable {
async resolveLibraryPath(
workspaces: string[],
queryPath: string,
silent = false,
): Promise<QuerySetup> {
const subcommandArgs = [
"--query",
@@ -728,6 +729,7 @@ export class CodeQLCliServer implements Disposable {
["resolve", "library-path"],
subcommandArgs,
"Resolving library paths",
{ silent },
);
}

View File

@@ -7,7 +7,7 @@ import { Logger } from "./logging";
* files. This class automatically prevents more than one discovery operation from running at the
* same time.
*/
export abstract class Discovery<T> extends DisposableObject {
export abstract class Discovery extends DisposableObject {
private restartWhenFinished = false;
private currentDiscoveryPromise: Promise<void> | undefined;
@@ -64,14 +64,12 @@ export abstract class Discovery<T> extends DisposableObject {
* discovery.
*/
private async launchDiscovery(): Promise<void> {
let results: T | undefined;
try {
results = await this.discover();
await this.discover();
} catch (err) {
void this.logger.log(
`${this.name} failed. Reason: ${getErrorMessage(err)}`,
);
results = undefined;
}
if (this.restartWhenFinished) {
@@ -82,24 +80,11 @@ export abstract class Discovery<T> extends DisposableObject {
// succeeded or failed.
this.restartWhenFinished = false;
await this.launchDiscovery();
} else {
// If the discovery was successful, then update any listeners with the results.
if (results !== undefined) {
this.update(results);
}
}
}
/**
* Overridden by the derived class to spawn the actual discovery operation, returning the results.
*/
protected abstract discover(): Promise<T>;
/**
* Overridden by the derived class to atomically update the `Discovery` object with the results of
* the discovery operation, and to notify any listeners that the discovery results may have
* changed.
* @param results The discovery results returned by the `discover` function.
*/
protected abstract update(results: T): void;
protected abstract discover(): Promise<void>;
}

View File

@@ -25,15 +25,15 @@ export const PACKS_BY_QUERY_LANGUAGE = {
[QueryLanguage.Ruby]: ["codeql/ruby-queries"],
};
export const dbSchemeToLanguage = {
"semmlecode.javascript.dbscheme": "javascript",
"semmlecode.cpp.dbscheme": "cpp",
"semmlecode.dbscheme": "java",
"semmlecode.python.dbscheme": "python",
"semmlecode.csharp.dbscheme": "csharp",
"go.dbscheme": "go",
"ruby.dbscheme": "ruby",
"swift.dbscheme": "swift",
export const dbSchemeToLanguage: Record<string, QueryLanguage> = {
"semmlecode.javascript.dbscheme": QueryLanguage.Javascript,
"semmlecode.cpp.dbscheme": QueryLanguage.Cpp,
"semmlecode.dbscheme": QueryLanguage.Java,
"semmlecode.python.dbscheme": QueryLanguage.Python,
"semmlecode.csharp.dbscheme": QueryLanguage.CSharp,
"go.dbscheme": QueryLanguage.Go,
"ruby.dbscheme": QueryLanguage.Ruby,
"swift.dbscheme": QueryLanguage.Swift,
};
export function isQueryLanguage(language: string): language is QueryLanguage {

View File

@@ -0,0 +1,245 @@
import { Discovery } from "../discovery";
import {
Event,
EventEmitter,
RelativePattern,
Uri,
WorkspaceFoldersChangeEvent,
workspace,
} from "vscode";
import { MultiFileSystemWatcher } from "./multi-file-system-watcher";
import { AppEventEmitter } from "../events";
import { extLogger } from "..";
import { lstat } from "fs-extra";
import { containsPath, isIOError } from "../../pure/files";
import {
getOnDiskWorkspaceFolders,
getOnDiskWorkspaceFoldersObjects,
} from "./workspace-folders";
interface PathData {
path: string;
}
/**
* Discovers and watches for changes to all files matching a given filter
* contained in the workspace. Also allows computing extra data about each
* file path, and only recomputing the data when the file changes.
*
* Scans the whole workspace on startup, and then watches for changes to files
* to do the minimum work to keep up with changes.
*
* Can configure which changes it watches for, which files are considered
* relevant, and what extra data to compute for each file.
*/
export abstract class FilePathDiscovery<T extends PathData> extends Discovery {
/** The set of known paths and associated data that we are tracking */
private pathData: T[] = [];
/** Event that fires whenever the contents of `pathData` changes */
private readonly onDidChangePathDataEmitter: AppEventEmitter<void>;
/**
* The set of file paths that may have changed on disk since the last time
* refresh was run. Whenever a watcher reports some change to a file we add
* it to this set, and then during the next refresh we will process all
* file paths from this set and update our internal state to match whatever
* we find on disk (i.e. the file exists, doesn't exist, computed data has
* changed).
*/
private readonly changedFilePaths = new Set<string>();
/**
* Watches for changes to files and directories in all workspace folders.
*/
private readonly watcher: MultiFileSystemWatcher = this.push(
new MultiFileSystemWatcher(),
);
/**
* @param name Name of the discovery operation, for logging purposes.
* @param fileWatchPattern Passed to `vscode.RelativePattern` to determine the files to watch for changes to.
*/
constructor(name: string, private readonly fileWatchPattern: string) {
super(name, extLogger);
this.onDidChangePathDataEmitter = this.push(new EventEmitter<void>());
this.push(
workspace.onDidChangeWorkspaceFolders(
this.workspaceFoldersChanged.bind(this),
),
);
this.push(this.watcher.onDidChange(this.fileChanged.bind(this)));
}
protected getPathData(): ReadonlyArray<Readonly<T>> {
return this.pathData;
}
protected get onDidChangePathData(): Event<void> {
return this.onDidChangePathDataEmitter.event;
}
/**
* Compute any extra data to be stored regarding the given path.
*/
protected abstract getDataForPath(path: string): Promise<T>;
/**
* Is the given path relevant to this discovery operation?
*/
protected abstract pathIsRelevant(path: string): boolean;
/**
* Should the given new data overwrite the existing data we have stored?
*/
protected abstract shouldOverwriteExistingData(
newData: T,
existingData: T,
): boolean;
/**
* Update the data for every path by calling `getDataForPath`.
*/
protected async recomputeAllData() {
this.pathData = await Promise.all(
this.pathData.map((p) => this.getDataForPath(p.path)),
);
this.onDidChangePathDataEmitter.fire();
}
/**
* Do the initial scan of the entire workspace and set up watchers for future changes.
*/
public async initialRefresh() {
getOnDiskWorkspaceFolders().forEach((workspaceFolder) => {
this.changedFilePaths.add(workspaceFolder);
});
this.updateWatchers();
return this.refresh();
}
private workspaceFoldersChanged(event: WorkspaceFoldersChangeEvent) {
event.added.forEach((workspaceFolder) => {
this.changedFilePaths.add(workspaceFolder.uri.fsPath);
});
event.removed.forEach((workspaceFolder) => {
this.changedFilePaths.add(workspaceFolder.uri.fsPath);
});
this.updateWatchers();
void this.refresh();
}
private updateWatchers() {
this.watcher.clear();
for (const workspaceFolder of getOnDiskWorkspaceFoldersObjects()) {
// Watch for changes to individual files
this.watcher.addWatch(
new RelativePattern(workspaceFolder, this.fileWatchPattern),
);
// need to explicitly watch for changes to directories themselves.
this.watcher.addWatch(new RelativePattern(workspaceFolder, "**/"));
}
}
private fileChanged(uri: Uri) {
this.changedFilePaths.add(uri.fsPath);
void this.refresh();
}
protected async discover() {
let pathsUpdated = false;
for (const path of this.changedFilePaths) {
this.changedFilePaths.delete(path);
if (await this.handleChangedPath(path)) {
pathsUpdated = true;
}
}
if (pathsUpdated) {
this.onDidChangePathDataEmitter.fire();
}
}
private async handleChangedPath(path: string): Promise<boolean> {
try {
// If the path is not in the workspace then we don't want to be
// tracking or displaying it, so treat it as if it doesn't exist.
if (!this.pathIsInWorkspace(path)) {
return this.handleRemovedPath(path);
}
if ((await lstat(path)).isDirectory()) {
return await this.handleChangedDirectory(path);
} else {
return this.handleChangedFile(path);
}
} catch (e) {
if (isIOError(e) && e.code === "ENOENT") {
return this.handleRemovedPath(path);
}
throw e;
}
}
private pathIsInWorkspace(path: string): boolean {
return getOnDiskWorkspaceFolders().some((workspaceFolder) =>
containsPath(workspaceFolder, path),
);
}
private handleRemovedPath(path: string): boolean {
const oldLength = this.pathData.length;
this.pathData = this.pathData.filter(
(existingPathData) => !containsPath(path, existingPathData.path),
);
return this.pathData.length !== oldLength;
}
private async handleChangedDirectory(path: string): Promise<boolean> {
const newPaths = await workspace.findFiles(
new RelativePattern(path, this.fileWatchPattern),
);
let pathsUpdated = false;
for (const path of newPaths) {
if (await this.addOrUpdatePath(path.fsPath)) {
pathsUpdated = true;
}
}
return pathsUpdated;
}
private async handleChangedFile(path: string): Promise<boolean> {
if (this.pathIsRelevant(path)) {
return await this.addOrUpdatePath(path);
} else {
return false;
}
}
private async addOrUpdatePath(path: string): Promise<boolean> {
const data = await this.getDataForPath(path);
const existingPathDataIndex = this.pathData.findIndex(
(existingPathData) => existingPathData.path === path,
);
if (existingPathDataIndex !== -1) {
if (
this.shouldOverwriteExistingData(
data,
this.pathData[existingPathDataIndex],
)
) {
this.pathData.splice(existingPathDataIndex, 1, data);
return true;
} else {
return false;
}
} else {
this.pathData.push(data);
return true;
}
}
}

View File

@@ -10,6 +10,7 @@ export function decodeBqrsToExternalApiUsages(
const usage = tuple[0] as Call;
const signature = tuple[1] as string;
const supported = (tuple[2] as string) === "true";
const library = tuple[4] as string;
const [packageWithType, methodDeclaration] = signature.split("#");
@@ -31,6 +32,7 @@ export function decodeBqrsToExternalApiUsages(
if (!methodsByApiName.has(signature)) {
methodsByApiName.set(signature, {
library,
signature,
packageName,
typeName,

View File

@@ -6,6 +6,10 @@ export type Call = {
};
export type ExternalApiUsage = {
/**
* Contains the name of the library containing the method declaration, e.g. `sql2o-1.6.0.jar` or `System.Runtime.dll`
*/
library: string;
/**
* Contains the full method signature, e.g. `org.sql2o.Connection#createQuery(String)`
*/

View File

@@ -26,7 +26,7 @@ where
apiName = api.getApiName() and
supported = isSupported(api) and
usage = aUsage(api)
select usage, apiName, supported.toString(), "supported"
select usage, apiName, supported.toString(), "supported", api.getFile().getBaseName(), "library"
`,
dependencies: {
"ExternalApi.qll": `/** Provides classes and predicates related to handling APIs from external libraries. */

View File

@@ -28,7 +28,7 @@ where
apiName = api.getApiName() and
supported = isSupported(api) and
usage = aUsage(api)
select usage, apiName, supported.toString(), "supported"
select usage, apiName, supported.toString(), "supported", api.jarContainer(), "library"
`,
dependencies: {
"ExternalApi.qll": `/** Provides classes and predicates related to handling APIs from external libraries. */

View File

@@ -7,6 +7,8 @@ export type Query = {
* - apiName: the name of the external API. This is a string.
* - supported: whether the external API is supported by the extension. This should be a string representation of a boolean to satify the result pattern for a problem query.
* - "supported": a string literal. This is required to make the query a valid problem query.
* - libraryName: the name of the library that contains the external API. This is a string and usually the basename of a file.
* - "library": a string literal. This is required to make the query a valid problem query.
*/
mainQuery: string;
dependencies?: {

View File

@@ -51,7 +51,7 @@ export async function getDirectoryNamesInsidePath(
return dirNames;
}
function normalizePath(path: string): string {
export function normalizePath(path: string): string {
// On Windows, "C:/", "C:\", and "c:/" are all equivalent. We need
// to normalize the paths to ensure they all get resolved to the
// same format. On Windows, we also need to do the comparison
@@ -107,3 +107,17 @@ export async function* walkDirectory(
}
}
}
/**
* Error thrown from methods from the `fs` module.
*
* In practice, any error matching this is likely an instance of `NodeJS.ErrnoException`.
* If desired in the future, we could model more fields or use `NodeJS.ErrnoException` directly.
*/
export interface IOError {
readonly code: string;
}
export function isIOError(e: any): e is IOError {
return e.code !== undefined && typeof e.code === "string";
}

View File

@@ -5,6 +5,7 @@ import { isCanary, showQueriesPanel } from "../config";
import { DisposableObject } from "../pure/disposable-object";
import { QueriesPanel } from "./queries-panel";
import { QueryDiscovery } from "./query-discovery";
import { QueryPackDiscovery } from "./query-pack-discovery";
export class QueriesModule extends DisposableObject {
private constructor(readonly app: App) {
@@ -19,9 +20,16 @@ export class QueriesModule extends DisposableObject {
}
void extLogger.log("Initializing queries panel.");
const queryDiscovery = new QueryDiscovery(app.environment, cliServer);
const queryPackDiscovery = new QueryPackDiscovery(cliServer);
this.push(queryPackDiscovery);
void queryPackDiscovery.initialRefresh();
const queryDiscovery = new QueryDiscovery(
app.environment,
queryPackDiscovery,
);
this.push(queryDiscovery);
void queryDiscovery.refresh();
void queryDiscovery.initialRefresh();
const queriesPanel = new QueriesPanel(queryDiscovery);
this.push(queriesPanel);

View File

@@ -1,154 +1,109 @@
import { dirname, basename, normalize, relative } from "path";
import { Discovery } from "../common/discovery";
import { CodeQLCliServer } from "../codeql-cli/cli";
import {
Event,
EventEmitter,
RelativePattern,
Uri,
WorkspaceFolder,
workspace,
} from "vscode";
import { MultiFileSystemWatcher } from "../common/vscode/multi-file-system-watcher";
import { Event } from "vscode";
import { EnvironmentContext } from "../common/app";
import { FileTreeDirectory, FileTreeLeaf } from "../common/file-tree-nodes";
import { getOnDiskWorkspaceFoldersObjects } from "../common/vscode/workspace-folders";
import { AppEventEmitter } from "../common/events";
import {
FileTreeDirectory,
FileTreeLeaf,
FileTreeNode,
} from "../common/file-tree-nodes";
import { QueryDiscoverer } from "./query-tree-data-provider";
import { extLogger } from "../common";
import { FilePathDiscovery } from "../common/vscode/file-path-discovery";
import { containsPath } from "../pure/files";
import { getOnDiskWorkspaceFoldersObjects } from "../common/vscode/workspace-folders";
import { QueryLanguage } from "../common/query-language";
/**
* The results of discovering queries.
*/
export interface QueryDiscoveryResults {
/**
* A tree of directories and query files.
* May have multiple roots because of multiple workspaces.
*/
queries: Array<FileTreeDirectory<string>>;
const QUERY_FILE_EXTENSION = ".ql";
/**
* File system paths to watch. If any ql file changes in these directories
* or any subdirectories, then this could signify a change in queries.
*/
watchPaths: Uri[];
export interface QueryPackDiscoverer {
getLanguageForQueryFile(queryPath: string): QueryLanguage | undefined;
onDidChangeQueryPacks: Event<void>;
}
interface Query {
path: string;
language: QueryLanguage | undefined;
}
/**
* Discovers all query files contained in the QL packs in a given workspace folder.
* Discovers all query files in the workspace.
*/
export class QueryDiscovery
extends Discovery<QueryDiscoveryResults>
extends FilePathDiscovery<Query>
implements QueryDiscoverer
{
private results: QueryDiscoveryResults | undefined;
private readonly onDidChangeQueriesEmitter: AppEventEmitter<void>;
private readonly watcher: MultiFileSystemWatcher = this.push(
new MultiFileSystemWatcher(),
);
constructor(
private readonly env: EnvironmentContext,
private readonly cliServer: CodeQLCliServer,
private readonly queryPackDiscovery: QueryPackDiscoverer,
) {
super("Query Discovery", extLogger);
super("Query Discovery", `**/*${QUERY_FILE_EXTENSION}`);
this.onDidChangeQueriesEmitter = this.push(new EventEmitter<void>());
this.push(workspace.onDidChangeWorkspaceFolders(this.refresh.bind(this)));
this.push(this.watcher.onDidChange(this.refresh.bind(this)));
}
public get queries(): Array<FileTreeDirectory<string>> | undefined {
return this.results?.queries;
this.push(
this.queryPackDiscovery.onDidChangeQueryPacks(
this.recomputeAllData.bind(this),
),
);
}
/**
* Event to be fired when the set of discovered queries may have changed.
* Event that fires when the set of queries in the workspace changes.
*/
public get onDidChangeQueries(): Event<void> {
return this.onDidChangeQueriesEmitter.event;
}
protected async discover(): Promise<QueryDiscoveryResults> {
const workspaceFolders = getOnDiskWorkspaceFoldersObjects();
if (workspaceFolders.length === 0) {
return {
queries: [],
watchPaths: [],
};
}
const queries = await this.discoverQueries(workspaceFolders);
return {
queries,
watchPaths: workspaceFolders.map((f) => f.uri),
};
}
protected update(results: QueryDiscoveryResults): void {
this.results = results;
this.watcher.clear();
for (const watchPath of results.watchPaths) {
// Watch for changes to any `.ql` file
this.watcher.addWatch(new RelativePattern(watchPath, "**/*.{ql}"));
// need to explicitly watch for changes to directories themselves.
this.watcher.addWatch(new RelativePattern(watchPath, "**/"));
}
this.onDidChangeQueriesEmitter.fire();
return this.onDidChangePathData;
}
/**
* Discover all queries in the specified directory and its subdirectories.
* @returns A `QueryDirectory` object describing the contents of the directory, or `undefined` if
* no queries were found.
* Return all known queries, represented as a tree.
*
* Trivial directories where there is only one child will be collapsed into a single node.
*/
private async discoverQueries(
workspaceFolders: readonly WorkspaceFolder[],
): Promise<Array<FileTreeDirectory<string>>> {
const rootDirectories = [];
for (const workspaceFolder of workspaceFolders) {
const root = await this.discoverQueriesInWorkspace(workspaceFolder);
if (root !== undefined) {
rootDirectories.push(root);
public buildQueryTree(): Array<FileTreeNode<string>> {
const roots = [];
for (const workspaceFolder of getOnDiskWorkspaceFoldersObjects()) {
const queriesInRoot = this.getPathData().filter((query) =>
containsPath(workspaceFolder.uri.fsPath, query.path),
);
if (queriesInRoot.length === 0) {
continue;
}
const root = new FileTreeDirectory<string>(
workspaceFolder.uri.fsPath,
workspaceFolder.name,
this.env,
);
for (const query of queriesInRoot) {
const dirName = dirname(normalize(relative(root.path, query.path)));
const parentDirectory = root.createDirectory(dirName);
parentDirectory.addChild(
new FileTreeLeaf<string>(
query.path,
basename(query.path),
query.language,
),
);
}
root.finish();
roots.push(root);
}
return rootDirectories;
return roots;
}
private async discoverQueriesInWorkspace(
workspaceFolder: WorkspaceFolder,
): Promise<FileTreeDirectory<string> | undefined> {
const fullPath = workspaceFolder.uri.fsPath;
const name = workspaceFolder.name;
protected async getDataForPath(path: string): Promise<Query> {
const language = this.determineQueryLanguage(path);
return { path, language };
}
// We don't want to log each invocation of resolveQueries, since it clutters up the log.
const silent = true;
const resolvedQueries = await this.cliServer.resolveQueries(
fullPath,
silent,
);
if (resolvedQueries.length === 0) {
return undefined;
}
protected pathIsRelevant(path: string): boolean {
return path.endsWith(QUERY_FILE_EXTENSION);
}
const rootDirectory = new FileTreeDirectory<string>(
fullPath,
name,
this.env,
);
for (const queryPath of resolvedQueries) {
const relativePath = normalize(relative(fullPath, queryPath));
const dirName = dirname(relativePath);
const parentDirectory = rootDirectory.createDirectory(dirName);
parentDirectory.addChild(
new FileTreeLeaf<string>(queryPath, basename(queryPath), "language"),
);
}
protected shouldOverwriteExistingData(
newData: Query,
existingData: Query,
): boolean {
return newData.language !== existingData.language;
}
rootDirectory.finish();
return rootDirectory;
private determineQueryLanguage(path: string): QueryLanguage | undefined {
return this.queryPackDiscovery.getLanguageForQueryFile(path);
}
}

View File

@@ -0,0 +1,107 @@
import { basename, dirname } from "path";
import { CodeQLCliServer, QuerySetup } from "../codeql-cli/cli";
import { Event } from "vscode";
import { QueryLanguage, dbSchemeToLanguage } from "../common/query-language";
import { FALLBACK_QLPACK_FILENAME, QLPACK_FILENAMES } from "../pure/ql";
import { FilePathDiscovery } from "../common/vscode/file-path-discovery";
import { getErrorMessage } from "../pure/helpers-pure";
import { extLogger } from "../common";
import { EOL } from "os";
import { containsPath } from "../pure/files";
import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders";
export interface QueryPack {
path: string;
language: QueryLanguage | undefined;
}
/**
* Discovers all query packs in the workspace.
*/
export class QueryPackDiscovery extends FilePathDiscovery<QueryPack> {
constructor(private readonly cliServer: CodeQLCliServer) {
super("Query Pack Discovery", `**/{${QLPACK_FILENAMES.join(",")}}`);
}
/**
* Event that fires when the set of query packs in the workspace changes.
*/
public get onDidChangeQueryPacks(): Event<void> {
return this.onDidChangePathData;
}
/**
* Given a path of a query file, locate the query pack that contains it and
* return the language of that pack. Returns undefined if no pack is found
* or the pack's language is unknown.
*/
public getLanguageForQueryFile(queryPath: string): QueryLanguage | undefined {
// Find all packs in a higher directory than the query
const packs = this.getPathData().filter((queryPack) =>
containsPath(dirname(queryPack.path), queryPath),
);
// Sort by descreasing path length to find the pack nearest the query
packs.sort((a, b) => b.path.length - a.path.length);
if (packs.length === 0) {
return undefined;
}
if (packs.length === 1) {
return packs[0].language;
}
// If the first two packs are from a different directory, then the first one is the nearest
if (dirname(packs[0].path) !== dirname(packs[1].path)) {
return packs[0].language;
}
// If the first two packs are from the same directory then look at the filenames
if (basename(packs[0].path) === FALLBACK_QLPACK_FILENAME) {
return packs[0].language;
} else {
return packs[1].language;
}
}
protected async getDataForPath(path: string): Promise<QueryPack> {
const language = await this.determinePackLanguage(path);
return { path, language };
}
private async determinePackLanguage(
path: string,
): Promise<QueryLanguage | undefined> {
let packInfo: QuerySetup | undefined = undefined;
try {
packInfo = await this.cliServer.resolveLibraryPath(
getOnDiskWorkspaceFolders(),
path,
true,
);
} catch (err) {
void extLogger.log(
`Query pack discovery failed to determine language for query pack: ${path}${EOL}Reason: ${getErrorMessage(
err,
)}`,
);
}
if (packInfo?.dbscheme === undefined) {
return undefined;
}
const dbscheme = basename(packInfo.dbscheme);
return dbSchemeToLanguage[dbscheme];
}
protected pathIsRelevant(path: string): boolean {
return QLPACK_FILENAMES.includes(basename(path));
}
protected shouldOverwriteExistingData(
newPack: QueryPack,
existingPack: QueryPack,
): boolean {
return existingPack.language !== newPack.language;
}
}

View File

@@ -4,7 +4,7 @@ import { DisposableObject } from "../pure/disposable-object";
import { FileTreeNode } from "../common/file-tree-nodes";
export interface QueryDiscoverer {
readonly queries: Array<FileTreeNode<string>> | undefined;
readonly buildQueryTree: () => Array<FileTreeNode<string>>;
readonly onDidChangeQueries: Event<void>;
}
@@ -34,9 +34,9 @@ export class QueryTreeDataProvider
}
private createTree(): QueryTreeViewItem[] {
return (this.queryDiscoverer.queries || []).map(
this.convertFileTreeNode.bind(this),
);
return this.queryDiscoverer
.buildQueryTree()
.map(this.convertFileTreeNode.bind(this));
}
private convertFileTreeNode(

View File

@@ -14,26 +14,10 @@ import { pathExists } from "fs-extra";
import { FileTreeDirectory, FileTreeLeaf } from "../common/file-tree-nodes";
import { extLogger } from "../common";
/**
* The results of discovering QL tests.
*/
interface QLTestDiscoveryResults {
/**
* A directory that contains one or more QL Tests, or other QLTestDirectories.
*/
testDirectory: FileTreeDirectory | undefined;
/**
* The file system path to a directory to watch. If any ql or qlref file changes in
* this directory, then this signifies a change in tests.
*/
watchPath: string;
}
/**
* Discovers all QL tests contained in the QL packs in a given workspace folder.
*/
export class QLTestDiscovery extends Discovery<QLTestDiscoveryResults> {
export class QLTestDiscovery extends Discovery {
private readonly _onDidChangeTests = this.push(new EventEmitter<void>());
private readonly watcher: MultiFileSystemWatcher = this.push(
new MultiFileSystemWatcher(),
@@ -69,24 +53,18 @@ export class QLTestDiscovery extends Discovery<QLTestDiscoveryResults> {
void this.refresh();
}
}
protected async discover(): Promise<QLTestDiscoveryResults> {
const testDirectory = await this.discoverTests();
return {
testDirectory,
watchPath: this.workspaceFolder.uri.fsPath,
};
}
protected update(results: QLTestDiscoveryResults): void {
this._testDirectory = results.testDirectory;
protected async discover() {
this._testDirectory = await this.discoverTests();
this.watcher.clear();
// Watch for changes to any `.ql` or `.qlref` file in any of the QL packs that contain tests.
this.watcher.addWatch(
new RelativePattern(results.watchPath, "**/*.{ql,qlref}"),
new RelativePattern(this.workspaceFolder.uri.fsPath, "**/*.{ql,qlref}"),
);
// need to explicitly watch for changes to directories themselves.
this.watcher.addWatch(new RelativePattern(results.watchPath, "**/"));
this.watcher.addWatch(
new RelativePattern(this.workspaceFolder.uri.fsPath, "**/"),
);
this._onDidChangeTests.fire(undefined);
}

View File

@@ -34,6 +34,7 @@ DataExtensionsEditor.args = {
},
initialExternalApiUsages: [
{
library: "sql2o-1.6.0.jar",
signature: "org.sql2o.Connection#createQuery(String)",
packageName: "org.sql2o",
typeName: "Connection",
@@ -64,6 +65,7 @@ DataExtensionsEditor.args = {
],
},
{
library: "sql2o-1.6.0.jar",
signature: "org.sql2o.Query#executeScalar(Class)",
packageName: "org.sql2o",
typeName: "Query",
@@ -94,6 +96,7 @@ DataExtensionsEditor.args = {
],
},
{
library: "sql2o-1.6.0.jar",
signature: "org.sql2o.Sql2o#open()",
packageName: "org.sql2o",
typeName: "Sql2o",
@@ -124,6 +127,7 @@ DataExtensionsEditor.args = {
],
},
{
library: "rt.jar",
signature: "java.io.PrintStream#println(String)",
packageName: "java.io",
typeName: "PrintStream",
@@ -144,6 +148,7 @@ DataExtensionsEditor.args = {
],
},
{
library: "spring-boot-3.0.2.jar",
signature:
"org.springframework.boot.SpringApplication#run(Class,String[])",
packageName: "org.springframework.boot",
@@ -165,6 +170,7 @@ DataExtensionsEditor.args = {
],
},
{
library: "sql2o-1.6.0.jar",
signature: "org.sql2o.Sql2o#Sql2o(String,String,String)",
packageName: "org.sql2o",
typeName: "Sql2o",
@@ -185,6 +191,7 @@ DataExtensionsEditor.args = {
],
},
{
library: "sql2o-1.6.0.jar",
signature: "org.sql2o.Sql2o#Sql2o(String)",
packageName: "org.sql2o",
typeName: "Sql2o",

View File

@@ -16,6 +16,7 @@ const Template: ComponentStory<typeof MethodRowComponent> = (args) => (
export const MethodRow = Template.bind({});
MethodRow.args = {
externalApiUsage: {
library: "sql2o-1.6.0.jar",
signature: "org.sql2o.Sql2o#open()",
packageName: "org.sql2o",
typeName: "Sql2o",

View File

@@ -4,16 +4,10 @@ import {
ShowProgressMessage,
ToDataExtensionsEditorMessage,
} from "../../pure/interface-types";
import {
VSCodeButton,
VSCodeDataGrid,
VSCodeDataGridCell,
VSCodeDataGridRow,
} from "@vscode/webview-ui-toolkit/react";
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react";
import styled from "styled-components";
import { ExternalApiUsage } from "../../data-extensions-editor/external-api-usage";
import { ModeledMethod } from "../../data-extensions-editor/modeled-method";
import { MethodRow } from "./MethodRow";
import { assertNever } from "../../pure/helpers-pure";
import { vscode } from "../vscode-api";
import { calculateModeledPercentage } from "./modeled";
@@ -21,6 +15,7 @@ import { LinkIconButton } from "../variant-analysis/LinkIconButton";
import { basename } from "../common/path";
import { ViewTitle } from "../common";
import { DataExtensionEditorViewState } from "../../data-extensions-editor/shared/view-state";
import { ModeledMethodsList } from "./ModeledMethodsList";
const DataExtensionsEditorContainer = styled.div`
margin-top: 1rem;
@@ -42,6 +37,12 @@ const EditorContainer = styled.div`
margin-top: 1rem;
`;
const ButtonsContainer = styled.div`
display: flex;
gap: 0.4em;
margin-bottom: 1rem;
`;
type ProgressBarProps = {
completion: number;
};
@@ -217,54 +218,24 @@ export function DataExtensionsEditor({
</DetailsContainer>
<EditorContainer>
<VSCodeButton onClick={onApplyClick}>Apply</VSCodeButton>
&nbsp;
<VSCodeButton onClick={onGenerateClick}>
Download and generate
</VSCodeButton>
{viewState?.showLlmButton && (
<>
&nbsp;
<VSCodeButton onClick={onGenerateFromLlmClick}>
Generate using LLM
</VSCodeButton>
</>
)}
<br />
<br />
<VSCodeDataGrid>
<VSCodeDataGridRow rowType="header">
<VSCodeDataGridCell cellType="columnheader" gridColumn={1}>
Type
</VSCodeDataGridCell>
<VSCodeDataGridCell cellType="columnheader" gridColumn={2}>
Method
</VSCodeDataGridCell>
<VSCodeDataGridCell cellType="columnheader" gridColumn={3}>
Usages
</VSCodeDataGridCell>
<VSCodeDataGridCell cellType="columnheader" gridColumn={4}>
Model type
</VSCodeDataGridCell>
<VSCodeDataGridCell cellType="columnheader" gridColumn={5}>
Input
</VSCodeDataGridCell>
<VSCodeDataGridCell cellType="columnheader" gridColumn={6}>
Output
</VSCodeDataGridCell>
<VSCodeDataGridCell cellType="columnheader" gridColumn={7}>
Kind
</VSCodeDataGridCell>
</VSCodeDataGridRow>
{externalApiUsages.map((externalApiUsage) => (
<MethodRow
key={externalApiUsage.signature}
externalApiUsage={externalApiUsage}
modeledMethod={modeledMethods[externalApiUsage.signature]}
onChange={onChange}
/>
))}
</VSCodeDataGrid>
<ButtonsContainer>
<VSCodeButton onClick={onApplyClick}>Apply</VSCodeButton>
<VSCodeButton onClick={onGenerateClick}>
Download and generate
</VSCodeButton>
{viewState?.showLlmButton && (
<>
<VSCodeButton onClick={onGenerateFromLlmClick}>
Generate using LLM
</VSCodeButton>
</>
)}
</ButtonsContainer>
<ModeledMethodsList
externalApiUsages={externalApiUsages}
modeledMethods={modeledMethods}
onChange={onChange}
/>
</EditorContainer>
</>
)}

View File

@@ -0,0 +1,60 @@
import * as React from "react";
import {
VSCodeDataGrid,
VSCodeDataGridCell,
VSCodeDataGridRow,
} from "@vscode/webview-ui-toolkit/react";
import { MethodRow } from "./MethodRow";
import { ExternalApiUsage } from "../../data-extensions-editor/external-api-usage";
import { ModeledMethod } from "../../data-extensions-editor/modeled-method";
type Props = {
externalApiUsages: ExternalApiUsage[];
modeledMethods: Record<string, ModeledMethod>;
onChange: (
externalApiUsage: ExternalApiUsage,
modeledMethod: ModeledMethod,
) => void;
};
export const ModeledMethodDataGrid = ({
externalApiUsages,
modeledMethods,
onChange,
}: Props) => {
return (
<VSCodeDataGrid>
<VSCodeDataGridRow rowType="header">
<VSCodeDataGridCell cellType="columnheader" gridColumn={1}>
Type
</VSCodeDataGridCell>
<VSCodeDataGridCell cellType="columnheader" gridColumn={2}>
Method
</VSCodeDataGridCell>
<VSCodeDataGridCell cellType="columnheader" gridColumn={3}>
Usages
</VSCodeDataGridCell>
<VSCodeDataGridCell cellType="columnheader" gridColumn={4}>
Model type
</VSCodeDataGridCell>
<VSCodeDataGridCell cellType="columnheader" gridColumn={5}>
Input
</VSCodeDataGridCell>
<VSCodeDataGridCell cellType="columnheader" gridColumn={6}>
Output
</VSCodeDataGridCell>
<VSCodeDataGridCell cellType="columnheader" gridColumn={7}>
Kind
</VSCodeDataGridCell>
</VSCodeDataGridRow>
{externalApiUsages.map((externalApiUsage) => (
<MethodRow
key={externalApiUsage.signature}
externalApiUsage={externalApiUsage}
modeledMethod={modeledMethods[externalApiUsage.signature]}
onChange={onChange}
/>
))}
</VSCodeDataGrid>
);
};

View File

@@ -0,0 +1,55 @@
import * as React from "react";
import { useMemo } from "react";
import styled from "styled-components";
import { ExternalApiUsage } from "../../data-extensions-editor/external-api-usage";
import { ModeledMethod } from "../../data-extensions-editor/modeled-method";
import { ModeledMethodDataGrid } from "./ModeledMethodDataGrid";
const LibraryContainer = styled.div`
margin-bottom: 1rem;
`;
type Props = {
externalApiUsages: ExternalApiUsage[];
modeledMethods: Record<string, ModeledMethod>;
onChange: (
externalApiUsage: ExternalApiUsage,
modeledMethod: ModeledMethod,
) => void;
};
export const ModeledMethodsList = ({
externalApiUsages,
modeledMethods,
onChange,
}: Props) => {
const groupedByLibrary = useMemo(() => {
const groupedByLibrary: Record<string, ExternalApiUsage[]> = {};
for (const externalApiUsage of externalApiUsages) {
groupedByLibrary[externalApiUsage.library] ??= [];
groupedByLibrary[externalApiUsage.library].push(externalApiUsage);
}
return groupedByLibrary;
}, [externalApiUsages]);
const sortedLibraryNames = useMemo(() => {
return Object.keys(groupedByLibrary).sort();
}, [groupedByLibrary]);
return (
<>
{sortedLibraryNames.map((libraryName) => (
<LibraryContainer key={libraryName}>
<h3>{libraryName}</h3>
<ModeledMethodDataGrid
externalApiUsages={groupedByLibrary[libraryName]}
modeledMethods={modeledMethods}
onChange={onChange}
/>
</LibraryContainer>
))}
</>
);
};

View File

@@ -1,5 +1,5 @@
[
"v2.13.3",
"v2.13.4",
"v2.12.7",
"v2.11.6",
"v2.7.6",

View File

@@ -13,6 +13,7 @@ import {
describe("createAutoModelRequest", () => {
const externalApiUsages: ExternalApiUsage[] = [
{
library: "spring-boot-3.0.2.jar",
signature:
"org.springframework.boot.SpringApplication#run(Class,String[])",
packageName: "org.springframework.boot",
@@ -34,6 +35,7 @@ describe("createAutoModelRequest", () => {
],
},
{
library: "sql2o-1.6.0.jar",
signature: "org.sql2o.Connection#createQuery(String)",
packageName: "org.sql2o",
typeName: "Connection",
@@ -64,6 +66,7 @@ describe("createAutoModelRequest", () => {
],
},
{
library: "sql2o-1.6.0.jar",
signature: "org.sql2o.Query#executeScalar(Class)",
packageName: "org.sql2o",
typeName: "Query",
@@ -94,6 +97,7 @@ describe("createAutoModelRequest", () => {
],
},
{
library: "sql2o-1.6.0.jar",
signature: "org.sql2o.Sql2o#open()",
packageName: "org.sql2o",
typeName: "Sql2o",
@@ -124,6 +128,7 @@ describe("createAutoModelRequest", () => {
],
},
{
library: "rt.jar",
signature: "java.io.PrintStream#println(String)",
packageName: "java.io",
typeName: "PrintStream",
@@ -144,6 +149,7 @@ describe("createAutoModelRequest", () => {
],
},
{
library: "sql2o-1.6.0.jar",
signature: "org.sql2o.Sql2o#Sql2o(String,String,String)",
packageName: "org.sql2o",
typeName: "Sql2o",
@@ -164,6 +170,7 @@ describe("createAutoModelRequest", () => {
],
},
{
library: "sql2o-1.6.0.jar",
signature: "org.sql2o.Sql2o#Sql2o(String)",
packageName: "org.sql2o",
typeName: "Sql2o",
@@ -184,6 +191,7 @@ describe("createAutoModelRequest", () => {
],
},
{
library: "test.jar",
signature: "org.test.MyClass#test()",
packageName: "org.test",
typeName: "MyClass",

View File

@@ -9,6 +9,7 @@ describe("createDataExtensionYaml", () => {
"java",
[
{
library: "sql2o-1.6.0.jar",
signature: "org.sql2o.Connection#createQuery(String)",
packageName: "org.sql2o",
typeName: "Connection",
@@ -39,6 +40,7 @@ describe("createDataExtensionYaml", () => {
],
},
{
library: "sql2o-1.6.0.jar",
signature: "org.sql2o.Query#executeScalar(Class)",
packageName: "org.sql2o",
typeName: "Query",

View File

@@ -0,0 +1,476 @@
import {
EventEmitter,
FileSystemWatcher,
Uri,
workspace,
WorkspaceFolder,
WorkspaceFoldersChangeEvent,
} from "vscode";
import { FilePathDiscovery } from "../../../../../src/common/vscode/file-path-discovery";
import { basename, dirname, join } from "path";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import * as tmp from "tmp";
import { normalizePath } from "../../../../../src/pure/files";
interface TestData {
path: string;
contents: string;
}
/**
* A test FilePathDiscovery that operates on files with the ".test" extension.
*/
class TestFilePathDiscovery extends FilePathDiscovery<TestData> {
constructor() {
super("TestFilePathDiscovery", "**/*.test");
}
public get onDidChangePaths() {
return this.onDidChangePathData;
}
public getPathData(): readonly TestData[] {
return super.getPathData();
}
protected async getDataForPath(path: string): Promise<TestData> {
return {
path,
contents: readFileSync(path, "utf8"),
};
}
protected pathIsRelevant(path: string): boolean {
return path.endsWith(".test");
}
protected shouldOverwriteExistingData(
newData: TestData,
existingData: TestData,
): boolean {
return newData.contents !== existingData.contents;
}
}
describe("FilePathDiscovery", () => {
let tmpDir: string;
let tmpDirRemoveCallback: (() => void) | undefined;
let workspaceFolder: WorkspaceFolder;
let workspacePath: string;
let workspaceFoldersSpy: jest.SpiedFunction<
() => typeof workspace.workspaceFolders
>;
const onDidCreateFile = new EventEmitter<Uri>();
const onDidChangeFile = new EventEmitter<Uri>();
const onDidDeleteFile = new EventEmitter<Uri>();
let createFileSystemWatcherSpy: jest.SpiedFunction<
typeof workspace.createFileSystemWatcher
>;
const onDidChangeWorkspaceFolders =
new EventEmitter<WorkspaceFoldersChangeEvent>();
let discovery: TestFilePathDiscovery;
beforeEach(() => {
const t = tmp.dirSync();
tmpDir = normalizePath(t.name);
tmpDirRemoveCallback = t.removeCallback;
workspaceFolder = {
uri: Uri.file(join(tmpDir, "workspace")),
name: "test",
index: 0,
};
workspacePath = workspaceFolder.uri.fsPath;
workspaceFoldersSpy = jest
.spyOn(workspace, "workspaceFolders", "get")
.mockReturnValue([workspaceFolder]);
const watcher: FileSystemWatcher = {
ignoreCreateEvents: false,
ignoreChangeEvents: false,
ignoreDeleteEvents: false,
onDidCreate: onDidCreateFile.event,
onDidChange: onDidChangeFile.event,
onDidDelete: onDidDeleteFile.event,
dispose: () => undefined,
};
createFileSystemWatcherSpy = jest
.spyOn(workspace, "createFileSystemWatcher")
.mockReturnValue(watcher);
jest
.spyOn(workspace, "onDidChangeWorkspaceFolders")
.mockImplementation(onDidChangeWorkspaceFolders.event);
discovery = new TestFilePathDiscovery();
});
afterEach(() => {
tmpDirRemoveCallback?.();
discovery.dispose();
});
describe("initialRefresh", () => {
it("should handle no files being present", async () => {
await discovery.initialRefresh();
expect(discovery.getPathData()).toEqual([]);
});
it("should recursively discover all test files", async () => {
makeTestFile(join(workspacePath, "123.test"));
makeTestFile(join(workspacePath, "456.test"));
makeTestFile(join(workspacePath, "bar", "789.test"));
await discovery.initialRefresh();
expect(new Set(discovery.getPathData())).toEqual(
new Set([
{ path: join(workspacePath, "123.test"), contents: "123" },
{ path: join(workspacePath, "456.test"), contents: "456" },
{ path: join(workspacePath, "bar", "789.test"), contents: "789" },
]),
);
});
it("should ignore non-test files", async () => {
makeTestFile(join(workspacePath, "1.test"));
makeTestFile(join(workspacePath, "2.foo"));
makeTestFile(join(workspacePath, "bar.ql"));
await discovery.initialRefresh();
expect(new Set(discovery.getPathData())).toEqual(
new Set([{ path: join(workspacePath, "1.test"), contents: "1" }]),
);
});
});
describe("file added", () => {
it("should discover a single new file", async () => {
await discovery.initialRefresh();
const didChangePathsListener = jest.fn();
discovery.onDidChangePaths(didChangePathsListener);
expect(discovery.getPathData()).toEqual([]);
const newFile = join(workspacePath, "1.test");
makeTestFile(newFile);
onDidCreateFile.fire(Uri.file(newFile));
await discovery.waitForCurrentRefresh();
expect(new Set(discovery.getPathData())).toEqual(
new Set([{ path: join(workspacePath, "1.test"), contents: "1" }]),
);
expect(didChangePathsListener).toHaveBeenCalled();
});
it("should do nothing if file doesnt actually exist", async () => {
await discovery.initialRefresh();
const didChangePathsListener = jest.fn();
discovery.onDidChangePaths(didChangePathsListener);
expect(discovery.getPathData()).toEqual([]);
onDidCreateFile.fire(Uri.file(join(workspacePath, "1.test")));
await discovery.waitForCurrentRefresh();
expect(discovery.getPathData()).toEqual([]);
expect(didChangePathsListener).not.toHaveBeenCalled();
});
it("should recursively discover a directory of new files", async () => {
await discovery.initialRefresh();
const didChangePathsListener = jest.fn();
discovery.onDidChangePaths(didChangePathsListener);
expect(discovery.getPathData()).toEqual([]);
const newDir = join(workspacePath, "foo");
makeTestFile(join(newDir, "1.test"));
makeTestFile(join(newDir, "bar", "2.test"));
makeTestFile(join(newDir, "bar", "3.test"));
onDidCreateFile.fire(Uri.file(newDir));
await discovery.waitForCurrentRefresh();
expect(new Set(discovery.getPathData())).toEqual(
new Set([
{ path: join(newDir, "1.test"), contents: "1" },
{ path: join(newDir, "bar", "2.test"), contents: "2" },
{ path: join(newDir, "bar", "3.test"), contents: "3" },
]),
);
expect(didChangePathsListener).toHaveBeenCalled();
});
it("should do nothing if file is already known", async () => {
const testFile = join(workspacePath, "1.test");
makeTestFile(testFile);
await discovery.initialRefresh();
const didChangePathsListener = jest.fn();
discovery.onDidChangePaths(didChangePathsListener);
expect(new Set(discovery.getPathData())).toEqual(
new Set([{ path: join(workspacePath, "1.test"), contents: "1" }]),
);
onDidCreateFile.fire(Uri.file(testFile));
await discovery.waitForCurrentRefresh();
expect(new Set(discovery.getPathData())).toEqual(
new Set([{ path: join(workspacePath, "1.test"), contents: "1" }]),
);
expect(didChangePathsListener).not.toHaveBeenCalled();
});
});
describe("file changed", () => {
it("should do nothing if nothing has actually changed", async () => {
const testFile = join(workspacePath, "123.test");
makeTestFile(testFile);
await discovery.initialRefresh();
const didChangePathsListener = jest.fn();
discovery.onDidChangePaths(didChangePathsListener);
expect(new Set(discovery.getPathData())).toEqual(
new Set([{ path: join(workspacePath, "123.test"), contents: "123" }]),
);
onDidChangeFile.fire(Uri.file(testFile));
await discovery.waitForCurrentRefresh();
expect(new Set(discovery.getPathData())).toEqual(
new Set([{ path: join(workspacePath, "123.test"), contents: "123" }]),
);
expect(didChangePathsListener).not.toHaveBeenCalled();
});
it("should update data if it has changed", async () => {
const testFile = join(workspacePath, "1.test");
makeTestFile(testFile, "foo");
await discovery.initialRefresh();
const didChangePathsListener = jest.fn();
discovery.onDidChangePaths(didChangePathsListener);
expect(new Set(discovery.getPathData())).toEqual(
new Set([{ path: join(workspacePath, "1.test"), contents: "foo" }]),
);
writeFileSync(testFile, "bar");
onDidChangeFile.fire(Uri.file(testFile));
await discovery.waitForCurrentRefresh();
expect(new Set(discovery.getPathData())).toEqual(
new Set([{ path: join(workspacePath, "1.test"), contents: "bar" }]),
);
expect(didChangePathsListener).toHaveBeenCalled();
});
});
describe("file deleted", () => {
it("should remove a file that has been deleted", async () => {
const testFile = join(workspacePath, "1.test");
makeTestFile(testFile);
await discovery.initialRefresh();
const didChangePathsListener = jest.fn();
discovery.onDidChangePaths(didChangePathsListener);
expect(new Set(discovery.getPathData())).toEqual(
new Set([{ path: join(workspacePath, "1.test"), contents: "1" }]),
);
rmSync(testFile);
onDidDeleteFile.fire(Uri.file(testFile));
await discovery.waitForCurrentRefresh();
expect(discovery.getPathData()).toEqual([]);
expect(didChangePathsListener).toHaveBeenCalled();
});
it("should do nothing if file still exists", async () => {
const testFile = join(workspacePath, "1.test");
makeTestFile(testFile);
await discovery.initialRefresh();
const didChangePathsListener = jest.fn();
discovery.onDidChangePaths(didChangePathsListener);
expect(new Set(discovery.getPathData())).toEqual(
new Set([{ path: join(workspacePath, "1.test"), contents: "1" }]),
);
onDidDeleteFile.fire(Uri.file(testFile));
await discovery.waitForCurrentRefresh();
expect(new Set(discovery.getPathData())).toEqual(
new Set([{ path: join(workspacePath, "1.test"), contents: "1" }]),
);
expect(didChangePathsListener).not.toHaveBeenCalled();
});
it("should remove a directory of files that has been deleted", async () => {
makeTestFile(join(workspacePath, "123.test"));
makeTestFile(join(workspacePath, "bar", "456.test"));
makeTestFile(join(workspacePath, "bar", "789.test"));
await discovery.initialRefresh();
const didChangePathsListener = jest.fn();
discovery.onDidChangePaths(didChangePathsListener);
expect(new Set(discovery.getPathData())).toEqual(
new Set([
{ path: join(workspacePath, "123.test"), contents: "123" },
{ path: join(workspacePath, "bar", "456.test"), contents: "456" },
{ path: join(workspacePath, "bar", "789.test"), contents: "789" },
]),
);
rmSync(join(workspacePath, "bar"), { recursive: true });
onDidDeleteFile.fire(Uri.file(join(workspacePath, "bar")));
await discovery.waitForCurrentRefresh();
expect(new Set(discovery.getPathData())).toEqual(
new Set([{ path: join(workspacePath, "123.test"), contents: "123" }]),
);
expect(didChangePathsListener).toHaveBeenCalled();
});
});
describe("workspaceFoldersChanged", () => {
it("initialRefresh establishes watchers", async () => {
await discovery.initialRefresh();
// Called twice for each workspace folder
expect(createFileSystemWatcherSpy).toHaveBeenCalledTimes(2);
});
it("should install watchers when workspace folders change", async () => {
await discovery.initialRefresh();
createFileSystemWatcherSpy.mockClear();
const previousWorkspaceFolders = workspace.workspaceFolders || [];
const newWorkspaceFolders: WorkspaceFolder[] = [
{
uri: Uri.file(join(tmpDir, "workspace2")),
name: "workspace2",
index: 0,
},
{
uri: Uri.file(join(tmpDir, "workspace3")),
name: "workspace3",
index: 1,
},
];
workspaceFoldersSpy.mockReturnValue(newWorkspaceFolders);
onDidChangeWorkspaceFolders.fire({
added: newWorkspaceFolders,
removed: previousWorkspaceFolders,
});
await discovery.waitForCurrentRefresh();
// Called twice for each workspace folder
expect(createFileSystemWatcherSpy).toHaveBeenCalledTimes(4);
});
it("should discover files in new workspaces", async () => {
makeTestFile(join(workspacePath, "123.test"));
await discovery.initialRefresh();
expect(new Set(discovery.getPathData())).toEqual(
new Set([{ path: join(workspacePath, "123.test"), contents: "123" }]),
);
const previousWorkspaceFolders = workspace.workspaceFolders || [];
const newWorkspaceFolders: WorkspaceFolder[] = [
workspaceFolder,
{
uri: Uri.file(join(tmpDir, "workspace2")),
name: "workspace2",
index: 1,
},
];
workspaceFoldersSpy.mockReturnValue(newWorkspaceFolders);
makeTestFile(join(tmpDir, "workspace2", "456.test"));
onDidChangeWorkspaceFolders.fire({
added: newWorkspaceFolders,
removed: previousWorkspaceFolders,
});
await discovery.waitForCurrentRefresh();
expect(new Set(discovery.getPathData())).toEqual(
new Set([
{ path: join(workspacePath, "123.test"), contents: "123" },
{ path: join(tmpDir, "workspace2", "456.test"), contents: "456" },
]),
);
});
it("should forgot files in old workspaces, even if the files on disk still exist", async () => {
const workspaceFolders: WorkspaceFolder[] = [
{
uri: Uri.file(join(tmpDir, "workspace1")),
name: "workspace1",
index: 0,
},
{
uri: Uri.file(join(tmpDir, "workspace2")),
name: "workspace2",
index: 1,
},
];
workspaceFoldersSpy.mockReturnValue(workspaceFolders);
makeTestFile(join(tmpDir, "workspace1", "123.test"));
makeTestFile(join(tmpDir, "workspace2", "456.test"));
await discovery.initialRefresh();
expect(new Set(discovery.getPathData())).toEqual(
new Set([
{ path: join(tmpDir, "workspace1", "123.test"), contents: "123" },
{ path: join(tmpDir, "workspace2", "456.test"), contents: "456" },
]),
);
workspaceFoldersSpy.mockReturnValue([workspaceFolders[0]]);
onDidChangeWorkspaceFolders.fire({
added: [],
removed: [workspaceFolders[1]],
});
await discovery.waitForCurrentRefresh();
expect(new Set(discovery.getPathData())).toEqual(
new Set([
{ path: join(tmpDir, "workspace1", "123.test"), contents: "123" },
]),
);
});
});
});
function makeTestFile(path: string, contents?: string) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, contents ?? basename(path, ".test"));
}

View File

@@ -1,203 +1,196 @@
import { EventEmitter, Uri, workspace } from "vscode";
import {
EventEmitter,
FileSystemWatcher,
Uri,
WorkspaceFoldersChangeEvent,
workspace,
} from "vscode";
import { CodeQLCliServer } from "../../../../src/codeql-cli/cli";
import { QueryDiscovery } from "../../../../src/queries-panel/query-discovery";
QueryDiscovery,
QueryPackDiscoverer,
} from "../../../../src/queries-panel/query-discovery";
import { createMockEnvironmentContext } from "../../../__mocks__/appMock";
import { mockedObject } from "../../utils/mocking.helpers";
import { basename, join, sep } from "path";
import { dirname, join } from "path";
import * as tmp from "tmp";
import {
FileTreeDirectory,
FileTreeLeaf,
} from "../../../../src/common/file-tree-nodes";
import { mkdirSync, writeFileSync } from "fs";
import { QueryLanguage } from "../../../../src/common/query-language";
import { sleep } from "../../../../src/pure/time";
describe("Query pack discovery", () => {
let tmpDir: string;
let tmpDirRemoveCallback: (() => void) | undefined;
let workspacePath: string;
const env = createMockEnvironmentContext();
const onDidChangeQueryPacks = new EventEmitter<void>();
let queryPackDiscoverer: QueryPackDiscoverer;
let discovery: QueryDiscovery;
describe("QueryDiscovery", () => {
beforeEach(() => {
expect(workspace.workspaceFolders?.length).toEqual(1);
const t = tmp.dirSync();
tmpDir = t.name;
tmpDirRemoveCallback = t.removeCallback;
const workspaceFolder = {
uri: Uri.file(join(tmpDir, "workspace")),
name: "workspace",
index: 0,
};
workspacePath = workspaceFolder.uri.fsPath;
jest
.spyOn(workspace, "workspaceFolders", "get")
.mockReturnValue([workspaceFolder]);
queryPackDiscoverer = {
getLanguageForQueryFile: () => QueryLanguage.Java,
onDidChangeQueryPacks: onDidChangeQueryPacks.event,
};
discovery = new QueryDiscovery(env, queryPackDiscoverer);
});
describe("queries", () => {
it("should return empty list when no QL files are present", async () => {
const resolveQueries = jest.fn().mockResolvedValue([]);
const cli = mockedObject<CodeQLCliServer>({
resolveQueries,
});
afterEach(() => {
tmpDirRemoveCallback?.();
discovery.dispose();
});
const discovery = new QueryDiscovery(createMockEnvironmentContext(), cli);
await discovery.refresh();
const queries = discovery.queries;
describe("buildQueryTree", () => {
it("returns an empty tree when there are no query files", async () => {
await discovery.initialRefresh();
expect(queries).toEqual([]);
expect(resolveQueries).toHaveBeenCalledTimes(1);
expect(discovery.buildQueryTree()).toEqual([]);
});
it("handles when query pack data is available", async () => {
makeTestFile(join(workspacePath, "query.ql"));
await discovery.initialRefresh();
expect(discovery.buildQueryTree()).toEqual([
new FileTreeDirectory(workspacePath, "workspace", env, [
new FileTreeLeaf(join(workspacePath, "query.ql"), "query.ql", "java"),
]),
]);
});
it("handles when query pack data is not available", async () => {
makeTestFile(join(workspacePath, "query.ql"));
queryPackDiscoverer.getLanguageForQueryFile = () => undefined;
await discovery.initialRefresh();
expect(discovery.buildQueryTree()).toEqual([
new FileTreeDirectory(workspacePath, "workspace", env, [
new FileTreeLeaf(
join(workspacePath, "query.ql"),
"query.ql",
undefined,
),
]),
]);
});
it("should organise query files into directories", async () => {
const workspaceRoot = workspace.workspaceFolders![0].uri.fsPath;
const cli = mockedObject<CodeQLCliServer>({
resolveQueries: jest
.fn()
.mockResolvedValue([
join(workspaceRoot, "dir1/query1.ql"),
join(workspaceRoot, "dir2/query2.ql"),
join(workspaceRoot, "query3.ql"),
makeTestFile(join(workspacePath, "dir1", "query1.ql"));
makeTestFile(join(workspacePath, "dir1", "query2.ql"));
makeTestFile(join(workspacePath, "dir2", "query3.ql"));
makeTestFile(join(workspacePath, "query4.ql"));
await discovery.initialRefresh();
expect(discovery.buildQueryTree()).toEqual([
new FileTreeDirectory(workspacePath, "workspace", env, [
new FileTreeDirectory(join(workspacePath, "dir1"), "dir1", env, [
new FileTreeLeaf(
join(workspacePath, "dir1", "query1.ql"),
"query1.ql",
"java",
),
new FileTreeLeaf(
join(workspacePath, "dir1", "query2.ql"),
"query2.ql",
"java",
),
]),
});
const discovery = new QueryDiscovery(createMockEnvironmentContext(), cli);
await discovery.refresh();
const queries = discovery.queries;
expect(queries).toBeDefined();
expect(queries![0].children.length).toEqual(3);
expect(queries![0].children[0].name).toEqual("dir1");
expect(queries![0].children[0].children.length).toEqual(1);
expect(queries![0].children[0].children[0].name).toEqual("query1.ql");
expect(queries![0].children[1].name).toEqual("dir2");
expect(queries![0].children[1].children.length).toEqual(1);
expect(queries![0].children[1].children[0].name).toEqual("query2.ql");
expect(queries![0].children[2].name).toEqual("query3.ql");
new FileTreeDirectory(join(workspacePath, "dir2"), "dir2", env, [
new FileTreeLeaf(
join(workspacePath, "dir2", "query3.ql"),
"query3.ql",
"java",
),
]),
new FileTreeLeaf(
join(workspacePath, "query4.ql"),
"query4.ql",
"java",
),
]),
]);
});
it("should collapse directories containing only a single element", async () => {
const workspaceRoot = workspace.workspaceFolders![0].uri.fsPath;
const cli = mockedObject<CodeQLCliServer>({
resolveQueries: jest
.fn()
.mockResolvedValue([
join(workspaceRoot, "dir1/query1.ql"),
join(workspaceRoot, "dir1/dir2/dir3/dir3/query2.ql"),
]),
});
makeTestFile(join(workspacePath, "query1.ql"));
makeTestFile(join(workspacePath, "foo", "bar", "baz", "query2.ql"));
const discovery = new QueryDiscovery(createMockEnvironmentContext(), cli);
await discovery.refresh();
const queries = discovery.queries;
expect(queries).toBeDefined();
await discovery.initialRefresh();
expect(queries![0].children.length).toEqual(1);
expect(queries![0].children[0].name).toEqual("dir1");
expect(queries![0].children[0].children.length).toEqual(2);
expect(queries![0].children[0].children[0].name).toEqual(
"dir2 / dir3 / dir3",
);
expect(queries![0].children[0].children[0].children.length).toEqual(1);
expect(queries![0].children[0].children[0].children[0].name).toEqual(
"query2.ql",
);
expect(queries![0].children[0].children[1].name).toEqual("query1.ql");
});
it("calls resolveQueries once for each workspace folder", async () => {
const workspaceRoots = [
`${sep}workspace1`,
`${sep}workspace2`,
`${sep}workspace3`,
];
jest.spyOn(workspace, "workspaceFolders", "get").mockReturnValueOnce(
workspaceRoots.map((root, index) => ({
uri: Uri.file(root),
name: basename(root),
index,
})),
);
const resolveQueries = jest.fn().mockImplementation((queryDir) => {
const workspaceIndex = workspaceRoots.indexOf(queryDir);
if (workspaceIndex === -1) {
throw new Error("Unexpected workspace");
}
return Promise.resolve([
join(queryDir, `query${workspaceIndex + 1}.ql`),
]);
});
const cli = mockedObject<CodeQLCliServer>({
resolveQueries,
});
const discovery = new QueryDiscovery(createMockEnvironmentContext(), cli);
await discovery.refresh();
const queries = discovery.queries;
expect(queries).toBeDefined();
expect(queries!.length).toEqual(3);
expect(queries![0].children[0].name).toEqual("query1.ql");
expect(queries![1].children[0].name).toEqual("query2.ql");
expect(queries![2].children[0].name).toEqual("query3.ql");
expect(resolveQueries).toHaveBeenCalledTimes(3);
expect(discovery.buildQueryTree()).toEqual([
new FileTreeDirectory(workspacePath, "workspace", env, [
new FileTreeDirectory(
join(workspacePath, "foo", "bar", "baz"),
"foo / bar / baz",
env,
[
new FileTreeLeaf(
join(workspacePath, "foo", "bar", "baz", "query2.ql"),
"query2.ql",
"java",
),
],
),
new FileTreeLeaf(
join(workspacePath, "query1.ql"),
"query1.ql",
"java",
),
]),
]);
});
});
describe("onDidChangeQueries", () => {
it("should fire onDidChangeQueries when a watcher fires", async () => {
const onWatcherDidChangeEvent = new EventEmitter<Uri>();
const watcher: FileSystemWatcher = {
ignoreCreateEvents: false,
ignoreChangeEvents: false,
ignoreDeleteEvents: false,
onDidCreate: onWatcherDidChangeEvent.event,
onDidChange: onWatcherDidChangeEvent.event,
onDidDelete: onWatcherDidChangeEvent.event,
dispose: () => undefined,
};
const createFileSystemWatcherSpy = jest.spyOn(
workspace,
"createFileSystemWatcher",
);
createFileSystemWatcherSpy.mockReturnValue(watcher);
describe("recomputeAllQueryLanguages", () => {
it("should recompute the language of all query files", async () => {
makeTestFile(join(workspacePath, "query.ql"));
const workspaceRoot = workspace.workspaceFolders![0].uri.fsPath;
const cli = mockedObject<CodeQLCliServer>({
resolveQueries: jest
.fn()
.mockResolvedValue([join(workspaceRoot, "query1.ql")]),
});
await discovery.initialRefresh();
const discovery = new QueryDiscovery(createMockEnvironmentContext(), cli);
expect(discovery.buildQueryTree()).toEqual([
new FileTreeDirectory(workspacePath, "workspace", env, [
new FileTreeLeaf(join(workspacePath, "query.ql"), "query.ql", "java"),
]),
]);
const onDidChangeQueriesSpy = jest.fn();
discovery.onDidChangeQueries(onDidChangeQueriesSpy);
queryPackDiscoverer.getLanguageForQueryFile = () => QueryLanguage.Python;
onDidChangeQueryPacks.fire();
await discovery.refresh();
// Wait for the query discovery to recompute the query languages.
// This is async but should complete instantly since it's all in-memory.
await sleep(100);
expect(createFileSystemWatcherSpy).toHaveBeenCalledTimes(2);
expect(onDidChangeQueriesSpy).toHaveBeenCalledTimes(1);
onWatcherDidChangeEvent.fire(workspace.workspaceFolders![0].uri);
await discovery.waitForCurrentRefresh();
expect(onDidChangeQueriesSpy).toHaveBeenCalledTimes(2);
});
});
describe("onDidChangeWorkspaceFolders", () => {
it("should refresh when workspace folders change", async () => {
const onDidChangeWorkspaceFoldersEvent =
new EventEmitter<WorkspaceFoldersChangeEvent>();
jest
.spyOn(workspace, "onDidChangeWorkspaceFolders")
.mockImplementation(onDidChangeWorkspaceFoldersEvent.event);
const discovery = new QueryDiscovery(
createMockEnvironmentContext(),
mockedObject<CodeQLCliServer>({
resolveQueries: jest.fn().mockResolvedValue([]),
}),
);
const onDidChangeQueriesSpy = jest.fn();
discovery.onDidChangeQueries(onDidChangeQueriesSpy);
await discovery.refresh();
expect(onDidChangeQueriesSpy).toHaveBeenCalledTimes(1);
onDidChangeWorkspaceFoldersEvent.fire({ added: [], removed: [] });
await discovery.waitForCurrentRefresh();
expect(onDidChangeQueriesSpy).toHaveBeenCalledTimes(2);
expect(discovery.buildQueryTree()).toEqual([
new FileTreeDirectory(workspacePath, "workspace", env, [
new FileTreeLeaf(
join(workspacePath, "query.ql"),
"query.ql",
"python",
),
]),
]);
});
});
});
function makeTestFile(path: string) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, "");
}

View File

@@ -0,0 +1,173 @@
import { Uri, workspace } from "vscode";
import { QueryPackDiscovery } from "../../../../src/queries-panel/query-pack-discovery";
import * as tmp from "tmp";
import { dirname, join } from "path";
import { CodeQLCliServer, QuerySetup } from "../../../../src/codeql-cli/cli";
import { mockedObject } from "../../utils/mocking.helpers";
import { mkdirSync, writeFileSync } from "fs";
describe("Query pack discovery", () => {
let tmpDir: string;
let tmpDirRemoveCallback: (() => void) | undefined;
let workspacePath: string;
let resolveLibraryPath: jest.SpiedFunction<
typeof CodeQLCliServer.prototype.resolveLibraryPath
>;
let discovery: QueryPackDiscovery;
beforeEach(() => {
const t = tmp.dirSync();
tmpDir = t.name;
tmpDirRemoveCallback = t.removeCallback;
const workspaceFolder = {
uri: Uri.file(join(tmpDir, "workspace")),
name: "workspace",
index: 0,
};
workspacePath = workspaceFolder.uri.fsPath;
jest
.spyOn(workspace, "workspaceFolders", "get")
.mockReturnValue([workspaceFolder]);
const mockResolveLibraryPathValue: QuerySetup = {
libraryPath: [],
dbscheme: "/ql/java/ql/lib/config/semmlecode.dbscheme",
};
resolveLibraryPath = jest
.fn()
.mockResolvedValue(mockResolveLibraryPathValue);
const mockCliServer = mockedObject<CodeQLCliServer>({ resolveLibraryPath });
discovery = new QueryPackDiscovery(mockCliServer);
});
afterEach(() => {
tmpDirRemoveCallback?.();
discovery.dispose();
});
describe("findQueryPack", () => {
it("returns undefined when there are no query packs", async () => {
await discovery.initialRefresh();
expect(
discovery.getLanguageForQueryFile(join(workspacePath, "query.ql")),
).toEqual(undefined);
});
it("locates a query pack in the same directory", async () => {
makeTestFile(join(workspacePath, "qlpack.yml"));
await discovery.initialRefresh();
expect(
discovery.getLanguageForQueryFile(join(workspacePath, "query.ql")),
).toEqual("java");
});
it("locates a query pack using the old pack name", async () => {
makeTestFile(join(workspacePath, "codeql-pack.yml"));
await discovery.initialRefresh();
expect(
discovery.getLanguageForQueryFile(join(workspacePath, "query.ql")),
).toEqual("java");
});
it("locates a query pack in a higher directory", async () => {
makeTestFile(join(workspacePath, "qlpack.yml"));
await discovery.initialRefresh();
expect(
discovery.getLanguageForQueryFile(
join(workspacePath, "foo", "bar", "query.ql"),
),
).toEqual("java");
});
it("doesn't recognise a query pack in a sibling directory", async () => {
makeTestFile(join(workspacePath, "foo", "qlpack.yml"));
await discovery.initialRefresh();
expect(
discovery.getLanguageForQueryFile(
join(workspacePath, "foo", "query.ql"),
),
).toEqual("java");
expect(
discovery.getLanguageForQueryFile(
join(workspacePath, "bar", "query.ql"),
),
).toEqual(undefined);
});
it("query packs override those from parent directories", async () => {
makeTestFile(join(workspacePath, "qlpack.yml"));
makeTestFile(join(workspacePath, "foo", "qlpack.yml"));
resolveLibraryPath.mockImplementation(async (_workspaces, queryPath) => {
if (queryPath === join(workspacePath, "qlpack.yml")) {
return {
libraryPath: [],
dbscheme: "/ql/java/ql/lib/config/semmlecode.dbscheme",
};
}
if (queryPath === join(workspacePath, "foo", "qlpack.yml")) {
return {
libraryPath: [],
dbscheme: "/ql/cpp/ql/lib/semmlecode.cpp.dbscheme",
};
}
throw new Error(`Unknown query pack: ${queryPath}`);
});
await discovery.initialRefresh();
expect(
discovery.getLanguageForQueryFile(join(workspacePath, "query.ql")),
).toEqual("java");
expect(
discovery.getLanguageForQueryFile(
join(workspacePath, "foo", "query.ql"),
),
).toEqual("cpp");
});
it("prefers a query pack called qlpack.yml", async () => {
makeTestFile(join(workspacePath, "qlpack.yml"));
makeTestFile(join(workspacePath, "codeql-pack.yml"));
resolveLibraryPath.mockImplementation(async (_workspaces, queryPath) => {
if (queryPath === join(workspacePath, "qlpack.yml")) {
return {
libraryPath: [],
dbscheme: "/ql/cpp/ql/lib/semmlecode.cpp.dbscheme",
};
}
if (queryPath === join(workspacePath, "codeql-pack.yml")) {
return {
libraryPath: [],
dbscheme: "/ql/java/ql/lib/config/semmlecode.dbscheme",
};
}
throw new Error(`Unknown query pack: ${queryPath}`);
});
await discovery.initialRefresh();
expect(
discovery.getLanguageForQueryFile(join(workspacePath, "query.ql")),
).toEqual("cpp");
});
});
});
function makeTestFile(path: string) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, "");
}

View File

@@ -3,25 +3,13 @@ import {
FileTreeDirectory,
FileTreeLeaf,
} from "../../../../src/common/file-tree-nodes";
import {
QueryDiscoverer,
QueryTreeDataProvider,
} from "../../../../src/queries-panel/query-tree-data-provider";
import { QueryTreeDataProvider } from "../../../../src/queries-panel/query-tree-data-provider";
describe("QueryTreeDataProvider", () => {
describe("getChildren", () => {
it("returns no children when queries is undefined", async () => {
const dataProvider = new QueryTreeDataProvider({
queries: undefined,
onDidChangeQueries: jest.fn(),
});
expect(dataProvider.getChildren()).toEqual([]);
});
it("returns no children when there are no queries", async () => {
const dataProvider = new QueryTreeDataProvider({
queries: [],
buildQueryTree: () => [],
onDidChangeQueries: jest.fn(),
});
@@ -30,7 +18,7 @@ describe("QueryTreeDataProvider", () => {
it("converts FileTreeNode to QueryTreeViewItem", async () => {
const dataProvider = new QueryTreeDataProvider({
queries: [
buildQueryTree: () => [
new FileTreeDirectory<string>("dir1", "dir1", env, [
new FileTreeDirectory<string>("dir1/dir2", "dir2", env, [
new FileTreeLeaf<string>(
@@ -75,20 +63,21 @@ describe("QueryTreeDataProvider", () => {
describe("onDidChangeQueries", () => {
it("should update tree when the queries change", async () => {
const queryTree = [
new FileTreeDirectory<string>("dir1", "dir1", env, [
new FileTreeLeaf<string>("dir1/file1", "file1", "javascript"),
]),
];
const onDidChangeQueriesEmitter = new EventEmitter<void>();
const queryDiscoverer: QueryDiscoverer = {
queries: [
new FileTreeDirectory<string>("dir1", "dir1", env, [
new FileTreeLeaf<string>("dir1/file1", "file1", "javascript"),
]),
],
const queryDiscoverer = {
buildQueryTree: () => queryTree,
onDidChangeQueries: onDidChangeQueriesEmitter.event,
};
const dataProvider = new QueryTreeDataProvider(queryDiscoverer);
expect(dataProvider.getChildren().length).toEqual(1);
queryDiscoverer.queries?.push(
queryTree.push(
new FileTreeDirectory<string>("dir2", "dir2", env, [
new FileTreeLeaf<string>("dir2/file2", "file2", "javascript"),
]),