diff --git a/extension.bundle.ts b/extension.bundle.ts index 168f3e38..eb28b400 100644 --- a/extension.bundle.ts +++ b/extension.bundle.ts @@ -22,3 +22,6 @@ export { contextManager } from "./src/contextManager"; export { DependencyExplorer } from "./src/views/dependencyExplorer"; export { Commands } from "./src/commands"; export { LanguageServerMode } from "./src/languageServerApi/LanguageServerMode"; + +// tasks +export { BuildTaskProvider, categorizePaths, getFinalPaths } from "./src/tasks/build/buildTaskProvider"; diff --git a/package.json b/package.json index 7ca87db3..c9e411a0 100644 --- a/package.json +++ b/package.json @@ -628,6 +628,40 @@ "description": "%taskDefinitions.java.project.exportJar.elements%" } } + }, + { + "type": "java (build)", + "properties": { + "paths": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "enum": [ + "${workspace}", + "!" + ], + "enumDescriptions": [ + "%taskDefinitions.java.project.build.path.workspace%", + "%taskDefinitions.java.project.build.path.exclude%" + ] + } + ] + }, + "default": [ + "${workspace}" + ], + "description": "%taskDefinitions.java.project.build.path%" + }, + "isFullBuild": { + "type": "boolean", + "default": "true", + "description": "%taskDefinitions.java.project.build.isFullBuild%" + } + } } ] }, diff --git a/package.nls.json b/package.nls.json index 9e3a3296..ba59ab8f 100644 --- a/package.nls.json +++ b/package.nls.json @@ -37,6 +37,10 @@ "taskDefinitions.java.project.exportJar.testCompileOutput": "The folders containing output class files in the test scope.", "taskDefinitions.java.project.exportJar.dependencies": "The artifact dependencies in the runtime scope.", "taskDefinitions.java.project.exportJar.testDependencies": "The artifact dependencies in the test scope.", + "taskDefinitions.java.project.build.path": "The project root paths that will be built. Both absolute path and relative path to the workspace folder are supported.", + "taskDefinitions.java.project.build.path.workspace": "All the projects in workspace.", + "taskDefinitions.java.project.build.path.exclude": "The path after '!' will be excluded from the paths to be built.", + "taskDefinitions.java.project.build.isFullBuild": "Whether to execute a clean build or not.", "viewsWelcome.workbench.createNewJavaProject": "You can also [open a Java project folder](command:_java.project.open), or create a new Java project by clicking the button below.\n[Create Java Project](command:java.project.create)", "viewsWelcome.workbench.noJavaProject": "No Java projects found in the current workspace. You can [open a Java project folder](command:_java.project.open), or create a new Java project by clicking the button below.\n[Create Java Project](command:java.project.create)", "viewsWelcome.workbench.inLightWeightMode": "To view the projects, you can import the projects into workspace.\n[Import Projects](command:java.server.mode.switch?%5B%22Standard%22,true%5D)", diff --git a/package.nls.zh.json b/package.nls.zh.json index f9fa60a5..7d9cedb9 100644 --- a/package.nls.zh.json +++ b/package.nls.zh.json @@ -37,6 +37,10 @@ "taskDefinitions.java.project.exportJar.testCompileOutput": "在 test scope 内包含输出的 class 文件的文件夹。", "taskDefinitions.java.project.exportJar.dependencies": "在 runtime scope 内的依赖。", "taskDefinitions.java.project.exportJar.testDependencies": "在 test scope 内的依赖。", + "taskDefinitions.java.project.build.path": "被构建项目的根目录路径。绝对路径或者相对于工作空间目录的相对路径都可以使用。", + "taskDefinitions.java.project.build.path.workspace": "工作空间中的所有项目。", + "taskDefinitions.java.project.build.path.exclude": "'!' 后的路径将会从待构建项目路径中移除。", + "taskDefinitions.java.project.build.isFullBuild": "是否要重新构建项目。", "viewsWelcome.workbench.createNewJavaProject": "您也可以[打开一个 Java 项目目录](command:_java.project.open),或点击下方按钮创建一个新的 Java 项目。\n[创建 Java 项目](command:java.project.create)", "viewsWelcome.workbench.noJavaProject": "当前工作空间未发现 Java 项目,您可以[打开一个 Java 项目目录](command:_java.project.open),或点击下方按钮创建一个新的 Java 项目。\n[创建 Java 项目](command:java.project.create)", "viewsWelcome.workbench.inLightWeightMode": "要浏览项目信息,你可以将项目导入到工作空间中。\n[导入项目](command:java.server.mode.switch?%5B%22Standard%22,true%5D)", diff --git a/src/build.ts b/src/build.ts index d0542c56..cd9ceca8 100644 --- a/src/build.ts +++ b/src/build.ts @@ -60,7 +60,7 @@ async function handleBuildFailure(operationId: string, err: any): Promise { + return await commands.executeCommand(Commands.EXECUTE_WORKSPACE_COMMAND, Commands.GET_ALL_PROJECTS) || []; + } + export async function refreshLibraries(params: string): Promise { return commands.executeCommand(Commands.EXECUTE_WORKSPACE_COMMAND, Commands.JAVA_PROJECT_REFRESH_LIB_SERVER, params); } diff --git a/src/tasks/build/buildTaskProvider.ts b/src/tasks/build/buildTaskProvider.ts new file mode 100644 index 00000000..6844d68b --- /dev/null +++ b/src/tasks/build/buildTaskProvider.ts @@ -0,0 +1,302 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import { CancellationTokenSource, commands, CustomExecution, Event, EventEmitter, Pseudoterminal, Task, + TaskDefinition, TaskGroup, TaskProvider, TaskRevealKind, TaskScope, Uri, workspace, WorkspaceFolder } from "vscode"; +import { Jdtls } from "../../java/jdtls"; +import * as path from "path"; +import { checkErrorsReportedByJavaExtension } from "../../build"; +import { Commands } from "../../commands"; +import { sendInfo } from "vscode-extension-telemetry-wrapper"; + +/** + * A task provider to provide Java build task support. + */ +export class BuildTaskProvider implements TaskProvider { + + public static readonly type = "java (build)"; + + // tslint:disable-next-line: no-invalid-template-strings + public static readonly workspace = "${workspace}"; + public static readonly defaultTaskName = "Build Workspace"; + + async provideTasks(): Promise { + const folders: readonly WorkspaceFolder[] = workspace.workspaceFolders || []; + if (!folders.length) { + return []; + } + const defaultTaskDefinition = { + type: BuildTaskProvider.type, + paths: [ BuildTaskProvider.workspace ], + isFullBuild: true, + }; + const defaultTask = new Task( + defaultTaskDefinition, + TaskScope.Workspace, + BuildTaskProvider.defaultTaskName, + BuildTaskProvider.type, + new CustomExecution(async (resolvedDefinition: IBuildTaskDefinition): Promise => { + return new BuildTaskTerminal(resolvedDefinition, TaskScope.Workspace); + }), + ); + defaultTask.detail = "$(tools) Build all the Java projects in workspace."; + defaultTask.group = TaskGroup.Build; + defaultTask.presentationOptions = { + reveal: TaskRevealKind.Never, + clear: true, + }; + return [defaultTask]; + } + + async resolveTask(task: Task): Promise { + const taskDefinition = task.definition as IBuildTaskDefinition; + if (!taskDefinition.paths?.length) { + taskDefinition.paths = [ BuildTaskProvider.workspace ]; + } else { + taskDefinition.paths = taskDefinition.paths + .map(p => p.trim()) + .filter(Boolean); + task.definition = taskDefinition; + } + task.execution = new CustomExecution(async (resolvedDefinition: IBuildTaskDefinition): Promise => { + return new BuildTaskTerminal(resolvedDefinition, task.scope ?? TaskScope.Workspace); + }); + task.presentationOptions = { + reveal: TaskRevealKind.Never, + clear: true, + }; + return task; + } +} + +class BuildTaskTerminal implements Pseudoterminal { + + private cancellationTokenSource: CancellationTokenSource; + + constructor(private readonly definition: IBuildTaskDefinition, + private readonly scope: WorkspaceFolder | TaskScope.Global | TaskScope.Workspace) { + this.cancellationTokenSource = new CancellationTokenSource(); + } + + writeEmitter = new EventEmitter(); + closeEmitter = new EventEmitter(); + + onDidWrite: Event = this.writeEmitter.event; + onDidClose: Event = this.closeEmitter.event; + + async open(): Promise { + // TODO: consider change to terminal name via changeNameEmitter. + // see: https://github.com/microsoft/vscode/issues/154146 + + let returnCode = Number.MAX_SAFE_INTEGER; + if (this.scope === TaskScope.Global) { + this.writeEmitter.fire('Global task is not supported.\r\n\r\n'); + returnCode = ReturnCode.UnsupportedTask; + } else if (this.definition.paths.length === 1 && + this.definition.paths[0] === BuildTaskProvider.workspace) { + returnCode = await this.buildWorkspace(); + } else { + returnCode = await this.buildProjects(); + } + + this.writeEmitter.fire('Task complete.\r\n'); + this.closeEmitter.fire(returnCode); + sendInfo("", { + buildTaskReturnCode: returnCode.toString() + }); + } + + close(): void { + this.cancellationTokenSource.cancel(); + this.cancellationTokenSource.dispose(); + } + + async buildWorkspace(): Promise { + this.writeEmitter.fire("Building all the Java projects in workspace...\r\n\r\n"); + try { + await commands.executeCommand(Commands.COMPILE_WORKSPACE, this.definition.isFullBuild, this.cancellationTokenSource.token); + } catch (e) { + if (checkErrorsReportedByJavaExtension()) { + commands.executeCommand(Commands.WORKBENCH_VIEW_PROBLEMS); + this.writeEmitter.fire("Errors found when building the workspace, please open PROBLEMS view for details.\r\n\r\n"); + return ReturnCode.UserError; + } else { + this.writeEmitter.fire("Errors occur when building the workspace:\r\n"); + this.writeEmitter.fire(`${e}\r\n\r\n`); + return ReturnCode.CommandFail; + } + } + return ReturnCode.Success; + } + + async buildProjects(): Promise { + // tslint:disable-next-line: prefer-const + let [includedPaths, excludedPaths, invalidPaths] = categorizePaths(this.definition.paths, this.scope); + if (invalidPaths.length) { + this.printList("Following paths are invalid, please provide absolute paths instead:", invalidPaths); + return ReturnCode.InvalidPath; + } + + const projectUris: string[] = await Jdtls.getProjectUris(); + const projectPaths: string[] = projectUris + .map(uri => Uri.parse(uri).fsPath) + .filter(p => path.basename(p) !== "jdt.ls-java-project"); + [includedPaths, invalidPaths] = getFinalPaths(includedPaths, excludedPaths, projectPaths); + + if (invalidPaths.length) { + this.printList("Following paths are skipped due to not matching any project root path:", invalidPaths); + } + + if (includedPaths.length === 0) { + return ReturnCode.EmptyIncludedPath; + } + + if (this.cancellationTokenSource.token.isCancellationRequested) { + return ReturnCode.Cancelled; + } + + this.printList("Building following projects:", includedPaths); + const uris: Uri[] = includedPaths.map(p => Uri.file(p)); + try { + const res = await commands.executeCommand(Commands.BUILD_PROJECT, uris, this.definition.isFullBuild, + this.cancellationTokenSource.token); + switch (res) { + case Jdtls.CompileWorkspaceStatus.Witherror: + if (checkErrorsReportedByJavaExtension()) { + commands.executeCommand(Commands.WORKBENCH_VIEW_PROBLEMS); + this.writeEmitter.fire("Errors found when building the workspace, please open PROBLEMS view for details.\r\n\r\n"); + } + return ReturnCode.UserError; + case Jdtls.CompileWorkspaceStatus.Cancelled: + return ReturnCode.Cancelled; + case Jdtls.CompileWorkspaceStatus.Failed: + return ReturnCode.CommandFail; + case Jdtls.CompileWorkspaceStatus.Succeed: + return ReturnCode.Success; + } + } catch (e) { + this.writeEmitter.fire(`Error occurs when building the workspace:\r\n`); + this.writeEmitter.fire(`${e}\r\n\r\n`); + return ReturnCode.CommandFail; + } + return ReturnCode.Success; + } + + private printList(title: string, list: string[]) { + this.writeEmitter.fire(`${title}\r\n`); + for (const l of list) { + this.writeEmitter.fire(` ${l}\r\n`); + } + this.writeEmitter.fire("\r\n"); + } +} + +/** + * Categorize the paths into three categories, and return the categories in an array. + * @param paths paths in the task definition. + * @param scope scope of the task + * @returns {Array} [included paths, excluded paths, invalid paths]. + */ +export function categorizePaths(paths: string[], scope: WorkspaceFolder | TaskScope.Global | TaskScope.Workspace): string[][] { + const includes = []; + const excludes = []; + const invalid = []; + for (const p of paths) { + let actualPath = p; + const isNegative: boolean = p.startsWith("!"); + if (isNegative) { + actualPath = trimNegativeSign(actualPath); + } + + if (actualPath === BuildTaskProvider.workspace || path.isAbsolute(actualPath)) { + if (isNegative) { + excludes.push(actualPath); + } else { + includes.push(actualPath); + } + continue; + } + + let folder: WorkspaceFolder | undefined; + if (scope === TaskScope.Workspace) { + // cannot recover the absolute path + if (!workspace.workspaceFolders || workspace.workspaceFolders.length > 1) { + invalid.push(p); + } else { + folder = workspace.workspaceFolders[0]; + } + } + + if (!folder) { + continue; + } + + const resolvedPath = path.join(folder.uri.fsPath, actualPath); + if (isNegative) { + excludes.push(resolvedPath); + } else { + includes.push(resolvedPath); + } + } + return [includes, excludes, invalid]; +} + +function trimNegativeSign(negativePath: string) { + let idx = 0; + for (; idx < negativePath.length; idx++) { + if (negativePath.charAt(idx) !== "!") { + break; + } + } + return negativePath.substring(idx); +} + +/** + * Get the final paths which will be passed to the build projects command. + * @param includes included paths. + * @param excludes excluded paths. + * @param projectPaths paths of all the projects. + * @returns {Array} [ final paths, invalid paths ]. + */ +export function getFinalPaths(includes: string[], excludes: string[], projectPaths: string[]): string[][] { + if (includes.includes(BuildTaskProvider.workspace)) { + includes = projectPaths; + } + + includes = includes.filter(p => { + return !excludes.some(excludePath => path.relative(excludePath, p) === ""); + }); + + const result: string[] = []; + const invalid: string[] = []; + for (const p of includes) { + const valid = projectPaths.some(projectPath => path.relative(projectPath, p) === ""); + if (valid) { + result.push(p); + } else { + invalid.push(p); + } + } + return [result, invalid]; +} + +interface IBuildTaskDefinition extends TaskDefinition { + /** + * The root paths of the projects to be built. + */ + paths: string[]; + /** + * Whether this is a full build or not. + */ + isFullBuild: boolean; +} + +enum ReturnCode { + Success = 0, + InvalidPath = 1, + UnsupportedTask = 2, + EmptyIncludedPath = 3, + UserError = 64, + CommandFail = -1, + Cancelled = -32800, +} diff --git a/test/suite/buildTask.test.ts b/test/suite/buildTask.test.ts new file mode 100644 index 00000000..8a4bab84 --- /dev/null +++ b/test/suite/buildTask.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from "assert"; +import { Task, tasks, TaskScope } from "vscode"; +import { BuildTaskProvider, categorizePaths, getFinalPaths } from "../../extension.bundle"; +import { setupTestEnv } from "../shared"; + +// tslint:disable: only-arrow-functions +// tslint:disable: no-object-literal-type-assertion +// tslint:disable: no-invalid-template-strings + +suite("Build Task Tests", () => { + + suiteSetup(setupTestEnv); + + test("test providing default build task", async function() { + const vscodeTasks: Task[] = await tasks.fetchTasks(); + const exportJarTask: Task | undefined = vscodeTasks.find((t: Task) => { + return t.name === BuildTaskProvider.defaultTaskName + && t.source === BuildTaskProvider.type; + }); + assert.ok(exportJarTask !== undefined); + }); + + test("test categorizePaths()", async function() { + const [includes, excludes, invalid] = categorizePaths([ + BuildTaskProvider.workspace, + "a/b/c", + "!foo" + ], TaskScope.Workspace); + assert.deepStrictEqual(includes.length, 2); + assert.deepStrictEqual(excludes.length, 1); + assert.deepStrictEqual(invalid.length, 0); + }); + + test("test getFinalPaths() 1", async function() { + const [result, invalid] = getFinalPaths([ + BuildTaskProvider.workspace, + "a/b/c", + ], [ + "foo/bar", + ], [ + "a/b/c", + "foo/bar", + "test/path" + ]); + assert.deepStrictEqual(result.length, 2); + assert.deepStrictEqual(invalid.length, 0); + }); + + test("test getFinalPaths() 2", async function() { + const [result, invalid] = getFinalPaths([ + "a/b/c", + "non/exist2" + ], [ + "foo/bar", + "non/exist" + ], [ + "a/b/c", + "foo/bar", + "test/path" + ]); + assert.deepStrictEqual(result.length, 1); + assert.deepStrictEqual(invalid.length, 1); + assert.deepStrictEqual(invalid[0], "non/exist2"); + }); +});