Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -825,7 +825,7 @@
"build-plugin": "node scripts/build-jdtls-ext.js",
"compile": "tsc -p ./",
"tslint": "eslint .",
"test:unit": "mocha",
"test:unit": "node scripts/run-unit-tests.js",
"test:autotest": "node scripts/run-autotest-plans.js test-plans",
"watch": "webpack --mode development --watch --progress",
"webpack": "webpack --mode development",
Expand Down
45 changes: 45 additions & 0 deletions scripts/run-unit-tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.

/**
* Wrapper that invokes mocha for the unit-test suite.
*
* Why this exists:
* Node 22.6+ ships `--experimental-strip-types` (on by default since 22.18).
* When that flag is enabled, Mocha can load newly added `test/unit/*.test.ts`
* files through Node's native ESM strip-types loader instead of the
* `ts-node/register/transpile-only` CommonJS hook configured in
* `.mocharc.json`. That breaks any test file that uses `require()`
* (e.g. for `proxyquire`) with:
* ReferenceError: require is not defined in ES module scope
*
* The fix is to pass `--no-experimental-strip-types` to Node so that all
* `.ts` test files load uniformly via ts-node. But that flag does not
* exist on Node 20, where the project's CI still runs, so we cannot put
* it directly in `.mocharc.json` — Node 20 would reject it as
* `bad option`.
*
* This wrapper detects the Node version at runtime and only forwards
* the flag on Node 22.6+. Node 20 runs mocha exactly as before.
*/

"use strict";

const { spawn } = require("child_process");

const [major, minor] = process.versions.node.split(".").map(Number);
const supportsStripTypesFlag = major > 22 || (major === 22 && minor >= 6);

const nodeArgs = [];
if (supportsStripTypesFlag) {
nodeArgs.push("--no-experimental-strip-types");
}
nodeArgs.push(require.resolve("mocha/bin/mocha.js"));
nodeArgs.push(...process.argv.slice(2));

const child = spawn(process.execPath, nodeArgs, { stdio: "inherit" });
child.on("close", code => process.exit(code ?? 1));
child.on("error", err => {
console.error(err);
process.exit(1);
});
20 changes: 15 additions & 5 deletions src/completion/PomCompletionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,28 @@ export class PomCompletionProvider implements vscode.CompletionItemProvider {

}

async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position, _token: vscode.CancellationToken, _context: vscode.CompletionContext): Promise<vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> | undefined> {
async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken, _context: vscode.CompletionContext): Promise<vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> | undefined> {
if (token.isCancellationRequested) {
return undefined;
}
const documentText: string = document.getText();
const cursorOffset: number = document.offsetAt(position);
const currentNode: Node | undefined = getCurrentNode(documentText, cursorOffset);
if (currentNode === undefined || currentNode.startIndex === null || currentNode.endIndex === null) {
return undefined;
}

const ret = [];
for (const provider of this.providers) {
ret.push(...await provider.provide(document, position, currentNode));
const results = await Promise.all(
this.providers.map(provider =>
provider.provide(document, position, currentNode, token).catch(err => {
console.error(err);
return [] as vscode.CompletionItem[];
})
)
);
if (token.isCancellationRequested) {
return undefined;
}
return ret;
return ([] as vscode.CompletionItem[]).concat(...results);
}
}
37 changes: 27 additions & 10 deletions src/completion/providers/ArtifactProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export class ArtifactProvider implements IXmlCompletionProvider {
this.localProvider = new FromLocal();
}

async provide(document: vscode.TextDocument, position: vscode.Position, currentNode: Node): Promise<vscode.CompletionItem[]> {
async provide(document: vscode.TextDocument, position: vscode.Position, currentNode: Node, token?: vscode.CancellationToken): Promise<vscode.CompletionItem[]> {
let tagNode: Element | undefined;
if (isTag(currentNode)) {
tagNode = currentNode;
Expand All @@ -48,9 +48,11 @@ export class ArtifactProvider implements IXmlCompletionProvider {
const groupIdHint: string = getTextFromNode(groupIdTextNode);
const artifactIdHint: string = getTextFromNode(artifactIdTextNode);

const centralItems: vscode.CompletionItem[] = await this.centralProvider.getGroupIdCandidates(groupIdHint, artifactIdHint);
const indexItems: vscode.CompletionItem[] = await this.indexProvider.getGroupIdCandidates(groupIdHint, artifactIdHint);
const localItems: vscode.CompletionItem[] = await this.localProvider.getGroupIdCandidates(groupIdHint);
const [centralItems, indexItems, localItems] = await settledAll([
this.centralProvider.getGroupIdCandidates(groupIdHint, artifactIdHint, token),
this.indexProvider.getGroupIdCandidates(groupIdHint, artifactIdHint),
this.localProvider.getGroupIdCandidates(groupIdHint),
]);
const mergedItems: vscode.CompletionItem[] = _.unionBy(centralItems, indexItems, localItems, (item) => item.insertText);
mergedItems.forEach(item => item.range = targetRange);
return mergedItems;
Expand All @@ -69,9 +71,11 @@ export class ArtifactProvider implements IXmlCompletionProvider {
const groupIdHint: string = getTextFromNode(groupIdTextNode);
const artifactIdHint: string = getTextFromNode(artifactIdTextNode);

const centralItems: vscode.CompletionItem[] = await this.centralProvider.getArtifactIdCandidates(groupIdHint, artifactIdHint);
const indexItems: vscode.CompletionItem[] = await this.indexProvider.getArtifactIdCandidates(groupIdHint, artifactIdHint);
const localItems: vscode.CompletionItem[] = await this.localProvider.getArtifactIdCandidates(groupIdHint);
const [centralItems, indexItems, localItems] = await settledAll([
this.centralProvider.getArtifactIdCandidates(groupIdHint, artifactIdHint, token),
this.indexProvider.getArtifactIdCandidates(groupIdHint, artifactIdHint),
this.localProvider.getArtifactIdCandidates(groupIdHint),
]);
let mergedItems: vscode.CompletionItem[] = [];

const ID_SEPARATOR = ":";
Expand Down Expand Up @@ -110,9 +114,11 @@ export class ArtifactProvider implements IXmlCompletionProvider {
return [];
}

const centralItems: vscode.CompletionItem[] = await this.centralProvider.getVersionCandidates(groupIdHint, artifactIdHint);
const indexItems: vscode.CompletionItem[] = await this.indexProvider.getVersionCandidates(groupIdHint, artifactIdHint);
const localItems: vscode.CompletionItem[] = await this.localProvider.getVersionCandidates(groupIdHint, artifactIdHint);
const [centralItems, indexItems, localItems] = await settledAll([
this.centralProvider.getVersionCandidates(groupIdHint, artifactIdHint, undefined, token),
this.indexProvider.getVersionCandidates(groupIdHint, artifactIdHint),
this.localProvider.getVersionCandidates(groupIdHint, artifactIdHint),
]);
const mergedItems: vscode.CompletionItem[] = _.unionBy(centralItems, indexItems, localItems, (item) => item.insertText);
mergedItems.forEach(item => item.range = targetRange);
return mergedItems;
Expand All @@ -122,6 +128,17 @@ export class ArtifactProvider implements IXmlCompletionProvider {
}
}

async function settledAll(promises: Promise<vscode.CompletionItem[]>[]): Promise<vscode.CompletionItem[][]> {
const results = await Promise.allSettled(promises);
return results.map(r => {
if (r.status === "fulfilled") {
return r.value;
}
console.error(r.reason);
return [];
});
}

function getRange(node: Node | null, document: vscode.TextDocument, fallbackPosition?: vscode.Position) {
if (fallbackPosition) {
return new vscode.Range(
Expand Down
2 changes: 1 addition & 1 deletion src/completion/providers/IXmlCompletionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ import { Node } from "domhandler";
import * as vscode from "vscode";

export interface IXmlCompletionProvider {
provide(document: vscode.TextDocument, position: vscode.Position, currentNode: Node): Promise<vscode.CompletionItem[]>;
provide(document: vscode.TextDocument, position: vscode.Position, currentNode: Node, token?: vscode.CancellationToken): Promise<vscode.CompletionItem[]>;
}
12 changes: 6 additions & 6 deletions src/completion/providers/artifact/FromCentral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { IArtifactCompletionProvider } from "./IArtifactProvider";
import { getSortText } from "../../utils";

export class FromCentral implements IArtifactCompletionProvider {
public async getGroupIdCandidates(groupIdHint: string, artifactIdHint: string): Promise<vscode.CompletionItem[]> {
public async getGroupIdCandidates(groupIdHint: string, artifactIdHint: string, token?: vscode.CancellationToken): Promise<vscode.CompletionItem[]> {
const keywords: string[] = [...groupIdHint.split("."), ...artifactIdHint.split("-")];
const docs: IArtifactMetadata[] = await getArtifacts(keywords);
const docs: IArtifactMetadata[] = await getArtifacts(keywords, token);
const groupIds: string[] = Array.from(new Set(docs.map(doc => doc.g)).values());
const commandOnSelection: vscode.Command = {
title: "selected", command: COMMAND_COMPLETION_ITEM_SELECTED,
Expand All @@ -25,9 +25,9 @@ export class FromCentral implements IArtifactCompletionProvider {
});
}

public async getArtifactIdCandidates(groupIdHint: string, artifactIdHint: string): Promise<vscode.CompletionItem[]> {
public async getArtifactIdCandidates(groupIdHint: string, artifactIdHint: string, token?: vscode.CancellationToken): Promise<vscode.CompletionItem[]> {
const keywords: string[] = [...groupIdHint.split("."), ...artifactIdHint.split("-")];
const docs: IArtifactMetadata[] = await getArtifacts(keywords);
const docs: IArtifactMetadata[] = await getArtifacts(keywords, token);
const commandOnSelection: vscode.Command = {
title: "selected", command: COMMAND_COMPLETION_ITEM_SELECTED,
arguments: [{ infoName: INFO_COMPLETION_ITEM_SELECTED, completeFor: "artifactId", source: "maven-central" }]
Expand All @@ -45,12 +45,12 @@ export class FromCentral implements IArtifactCompletionProvider {
});
}

public async getVersionCandidates(groupId: string, artifactId: string): Promise<vscode.CompletionItem[]> {
public async getVersionCandidates(groupId: string, artifactId: string, _versionHint?: string, token?: vscode.CancellationToken): Promise<vscode.CompletionItem[]> {
if (!groupId && !artifactId) {
return [];
}

const docs: IVersionMetadata[] = await getVersions(groupId, artifactId);
const docs: IVersionMetadata[] = await getVersions(groupId, artifactId, token);
const commandOnSelection: vscode.Command = {
title: "selected", command: COMMAND_COMPLETION_ITEM_SELECTED,
arguments: [{ infoName: INFO_COMPLETION_ITEM_SELECTED, completeFor: "version", source: "maven-central" }]
Expand Down
8 changes: 4 additions & 4 deletions src/completion/providers/artifact/IArtifactProvider.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.

import { CompletionItem } from "vscode";
import { CancellationToken, CompletionItem } from "vscode";

export interface IArtifactCompletionProvider {
getGroupIdCandidates(groupIdHint?: string, artifactIdHint?: string): Promise<CompletionItem[]>;
getArtifactIdCandidates(groupIdHint?: string, artifactIdHint?: string): Promise<CompletionItem[]>;
getVersionCandidates(groupIdHint?: string, artifactIdHint?: string, versionHint?: string): Promise<CompletionItem[]>;
getGroupIdCandidates(groupIdHint?: string, artifactIdHint?: string, token?: CancellationToken): Promise<CompletionItem[]>;
getArtifactIdCandidates(groupIdHint?: string, artifactIdHint?: string, token?: CancellationToken): Promise<CompletionItem[]>;
getVersionCandidates(groupIdHint?: string, artifactIdHint?: string, versionHint?: string, token?: CancellationToken): Promise<CompletionItem[]>;
}
83 changes: 72 additions & 11 deletions src/utils/requestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,29 @@ import * as https from "https";
import * as _ from "lodash";
import * as path from "path";
import * as url from "url";
import * as vscode from "vscode";

const URL_MAVEN_SEARCH_API = "https://search.maven.org/solrsearch/select";
const URL_MAVEN_CENTRAL_REPO = "https://repo1.maven.org/maven2/";
const MAVEN_METADATA_FILENAME = "maven-metadata.xml";
const HTTPS_GET_TIMEOUT_MS = 10_000;

/**
* Error thrown when an httpsGet call is aborted because its CancellationToken
* was cancelled (e.g. the user kept typing, so VS Code superseded the previous
* completion request). Callers should swallow this silently — it is not a
* real failure.
*/
class CancellationError extends Error {
constructor() {
super("Cancelled");
this.name = "CancellationError";
}
}

function isCancellation(error: unknown): boolean {
return error instanceof CancellationError;
}

export interface IArtifactMetadata {
id: string;
Expand All @@ -28,7 +47,7 @@ export interface IVersionMetadata {
timestamp: number;
}

export async function getArtifacts(keywords: string[]): Promise<IArtifactMetadata[]> {
export async function getArtifacts(keywords: string[], token?: vscode.CancellationToken): Promise<IArtifactMetadata[]> {
// Remove short keywords
const validKeywords: string[] = keywords.filter(keyword => keyword.length >= 3);
if (validKeywords.length === 0) {
Expand All @@ -40,27 +59,31 @@ export async function getArtifacts(keywords: string[]): Promise<IArtifactMetadat
rows: 50,
wt: "json"
};
const raw: string = await httpsGet(`${URL_MAVEN_SEARCH_API}?${toQueryString(params)}`);
try {
const raw: string = await httpsGet(`${URL_MAVEN_SEARCH_API}?${toQueryString(params)}`, token);
return _.get(JSON.parse(raw), "response.docs", []);
} catch (error) {
console.error(error);
if (!isCancellation(error)) {
console.error(error);
}
return [];
}
}

export async function getVersions(gid: string, aid: string): Promise<IVersionMetadata[]> {
export async function getVersions(gid: string, aid: string, token?: vscode.CancellationToken): Promise<IVersionMetadata[]> {
const params = {
q: `g:"${gid}" AND a:"${aid}"`,
core: "gav",
rows: 50,
wt: "json"
};
const raw: string = await httpsGet(`${URL_MAVEN_SEARCH_API}?${toQueryString(params)}`);
try {
const raw: string = await httpsGet(`${URL_MAVEN_SEARCH_API}?${toQueryString(params)}`, token);
return _.get(JSON.parse(raw), "response.docs", []);
} catch (error) {
console.error(error);
if (!isCancellation(error)) {
console.error(error);
}
return [];
}
}
Expand All @@ -80,25 +103,63 @@ export async function getLatestVersion(gid: string, aid: string): Promise<string
}
}

async function httpsGet(urlString: string): Promise<string> {
async function httpsGet(urlString: string, token?: vscode.CancellationToken): Promise<string> {
return new Promise<string>((resolve, reject) => {
if (token?.isCancellationRequested) {
reject(new CancellationError());
return;
}

let result = "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options: any = url.parse(urlString);
options.headers = {
'User-Agent': 'vscode-maven/0.1'
}
https.get(options, (res: http.IncomingMessage) => {
};
options.timeout = HTTPS_GET_TIMEOUT_MS;

let settled = false;
let cancelSub: vscode.Disposable | undefined;
const settle = (fn: () => void) => {
if (settled) {
return;
}
settled = true;
cancelSub?.dispose();
fn();
};

const req = https.get(options, (res: http.IncomingMessage) => {
res.on("data", chunk => {
result = result.concat(chunk.toString());
});
res.on("end", () => {
resolve(result);
settle(() => resolve(result));
});
res.on("error", err => {
reject(err);
settle(() => reject(err));
});
});

req.on("timeout", () => {
req.destroy(new Error(`HTTPS request to ${urlString} timed out after ${HTTPS_GET_TIMEOUT_MS}ms`));
});
req.on("error", err => {
settle(() => reject(err));
});

if (token) {
cancelSub = token.onCancellationRequested(() => {
req.destroy(new CancellationError());
});
// Defensive: if the request errored synchronously before we got
// here, `settled` is already true and the listener above would
// never be disposed. Drop it now in that case.
if (settled) {
cancelSub.dispose();
cancelSub = undefined;
}
}
});
}

Expand Down
Loading
Loading