From b53bc0e8c552317239d01a43f30af8c4dcad0f24 Mon Sep 17 00:00:00 2001 From: elaihau Date: Tue, 14 May 2019 13:33:02 -0400 Subject: [PATCH] create problem markers from task output What's included in this PR: - support taskDefinition, problemMatcher, & problemPattern contributed by Theia plugins. - support customizing tasks by adding named & anonymous problem matchers and patterns in .theia/tasks.json. - support parsing output from both "start - finish" tasks and "watch" tasks. - support both single line and multi line patterns. - create problem markers on parsing task output with the plugin-defined or user-defined problem matchers and patterns. What's not included: - prompt users to provide problem matcher and / or patterns before starting tasks - remove problem markers automatically when problems are fixed Signed-off-by: elaihau --- packages/cpp/src/browser/cpp-task-provider.ts | 23 +- .../plugin-ext/src/common/plugin-protocol.ts | 7 + .../src/hosted/browser/hosted-plugin.ts | 2 +- .../src/hosted/node/scanners/scanner-theia.ts | 13 + .../browser/plugin-contribution-handler.ts | 24 +- packages/plugin-ext/src/plugin/types-impl.ts | 6 +- packages/process/src/node/terminal-process.ts | 1 - packages/task/package.json | 3 +- packages/task/src/browser/quick-open-task.ts | 45 +-- .../task/src/browser/task-configurations.ts | 43 ++- .../browser/task-definition-registry.spec.ts | 120 +++++++ .../src/browser/task-definition-registry.ts | 65 ++++ .../task/src/browser/task-frontend-module.ts | 9 +- .../browser/task-problem-matcher-registry.ts | 219 +++++++++++++ .../browser/task-problem-pattern-registry.ts | 177 +++++++++++ packages/task/src/browser/task-service.ts | 136 +++++++- packages/task/src/common/index.ts | 1 + .../src/common/problem-matcher-protocol.ts | 248 +++++++++++++++ packages/task/src/common/task-protocol.ts | 130 +++++++- packages/task/src/common/task-watcher.ts | 10 +- .../task/src/node/process/process-task.ts | 68 +++- .../src/node/task-abstract-line-matcher.ts | 293 ++++++++++++++++++ packages/task/src/node/task-backend-module.ts | 3 +- packages/task/src/node/task-line-matchers.ts | 119 +++++++ .../src/node/task-problem-collector.spec.ts | 259 ++++++++++++++++ .../task/src/node/task-problem-collector.ts | 47 +++ packages/task/src/node/task-server.ts | 68 +++- packages/task/src/node/task.ts | 19 +- 28 files changed, 2087 insertions(+), 71 deletions(-) create mode 100644 packages/task/src/browser/task-definition-registry.spec.ts create mode 100644 packages/task/src/browser/task-definition-registry.ts create mode 100644 packages/task/src/browser/task-problem-matcher-registry.ts create mode 100644 packages/task/src/browser/task-problem-pattern-registry.ts create mode 100644 packages/task/src/common/problem-matcher-protocol.ts create mode 100644 packages/task/src/node/task-abstract-line-matcher.ts create mode 100644 packages/task/src/node/task-line-matchers.ts create mode 100644 packages/task/src/node/task-problem-collector.spec.ts create mode 100644 packages/task/src/node/task-problem-collector.ts diff --git a/packages/cpp/src/browser/cpp-task-provider.ts b/packages/cpp/src/browser/cpp-task-provider.ts index c2e95635c3a24..5a2442dee22f5 100644 --- a/packages/cpp/src/browser/cpp-task-provider.ts +++ b/packages/cpp/src/browser/cpp-task-provider.ts @@ -15,11 +15,11 @@ ********************************************************************************/ import parseArgv = require('string-argv'); -import { inject, injectable } from 'inversify'; +import { inject, injectable, postConstruct } from 'inversify'; import { ProcessTaskConfiguration } from '@theia/task/lib/common/process/task-protocol'; import { TaskContribution, TaskProvider, TaskProviderRegistry, TaskResolver, TaskResolverRegistry } from '@theia/task/lib/browser/task-contribution'; import { CppBuildConfigurationManager, CppBuildConfiguration } from './cpp-build-configurations'; -import { ContributedTaskConfiguration, TaskConfiguration } from '@theia/task/lib/common/task-protocol'; +import { ContributedTaskConfiguration, TaskConfiguration, TaskDefinitionRegistry } from '@theia/task/lib/common/task-protocol'; /** * Data required to define a C/C++ build task the user could run. @@ -35,8 +35,14 @@ const CPP_BUILD_TASK_SOURCE: string = 'cpp'; export class CppTaskProvider implements TaskContribution, TaskProvider, TaskResolver { @inject(TaskResolverRegistry) protected readonly taskResolverRegistry: TaskResolverRegistry; + @inject(TaskDefinitionRegistry) protected readonly taskDefinitionRegistry: TaskDefinitionRegistry; @inject(CppBuildConfigurationManager) protected readonly cppBuildConfigurationManager: CppBuildConfigurationManager; + @postConstruct() + protected init(): void { + this.registerTaskDefinition(); + } + registerProviders(registry: TaskProviderRegistry) { registry.register(CPP_BUILD_TASK_SOURCE, this); } @@ -108,4 +114,17 @@ export class CppTaskProvider implements TaskContribution, TaskProvider, TaskReso return taskConfigs; } + + private registerTaskDefinition(): void { + this.taskDefinitionRegistry.register({ + type: CPP_BUILD_TASK_TYPE_KEY, + required: ['label'], + properties: { + label: { + type: 'string', + description: 'Lable of the cpp build configuration task' + } + } + }, CPP_BUILD_TASK_SOURCE); + } } diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index 3eab9757a46c9..2d7c268c8ae83 100644 --- a/packages/plugin-ext/src/common/plugin-protocol.ts +++ b/packages/plugin-ext/src/common/plugin-protocol.ts @@ -22,6 +22,7 @@ import { ExtPluginApi } from './plugin-ext-api-contribution'; import { IJSONSchema, IJSONSchemaSnippet } from '@theia/core/lib/common/json-schema'; import { RecursivePartial } from '@theia/core/lib/common/types'; import { PreferenceSchema, PreferenceSchemaProperties } from '@theia/core/lib/common/preferences/preference-schema'; +import { TaskDefinitionContribution, ProblemMatcherContribution, ProblemPatternContribution } from '@theia/task/lib/common'; export const hostedServicePath = '/services/hostedPlugin'; @@ -71,6 +72,9 @@ export interface PluginPackageContribution { keybindings?: PluginPackageKeybinding[]; debuggers?: PluginPackageDebuggersContribution[]; snippets: PluginPackageSnippetsContribution[]; + taskDefinitions?: TaskDefinitionContribution[]; + problemMatchers?: ProblemMatcherContribution[]; + problemPatterns?: ProblemPatternContribution[]; } export interface PluginPackageViewContainer { @@ -364,6 +368,9 @@ export interface PluginContribution { keybindings?: Keybinding[]; debuggers?: DebuggerContribution[]; snippets?: SnippetContribution[]; + taskDefinitions?: TaskDefinitionContribution[]; + problemMatchers?: ProblemMatcherContribution[]; + problemPatterns?: ProblemPatternContribution[]; } export interface SnippetContribution { diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts index 5534dde4731ea..daa9e3f3226e9 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts @@ -200,7 +200,7 @@ export class HostedPluginSupport { } if (plugin.model.contributes) { - this.contributionHandler.handleContributions(plugin.model.contributes); + this.contributionHandler.handleContributions(plugin.model.contributes, plugin.model.id); } } diff --git a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts index 9f34f8d3a4501..e1afe8f9fd9ba 100644 --- a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts +++ b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts @@ -55,6 +55,7 @@ import { deepClone } from '@theia/core/lib/common/objects'; import { FileUri } from '@theia/core/lib/node/file-uri'; import { PreferenceSchema, PreferenceSchemaProperties } from '@theia/core/lib/common/preferences/preference-schema'; import { RecursivePartial } from '@theia/core/lib/common/types'; +import { TaskDefinitionContribution, ProblemMatcherContribution, ProblemPatternContribution } from '@theia/task/lib/common/task-protocol'; namespace nls { export function localize(key: string, _default: string) { @@ -183,6 +184,18 @@ export class TheiaPluginScanner implements PluginScanner { contributions.debuggers = debuggers; } + if (rawPlugin.contributes!.taskDefinitions) { + contributions.taskDefinitions = rawPlugin.contributes!.taskDefinitions as TaskDefinitionContribution[]; + } + + if (rawPlugin.contributes!.problemMatchers) { + contributions.problemMatchers = rawPlugin.contributes!.problemMatchers as ProblemMatcherContribution[]; + } + + if (rawPlugin.contributes!.problemPatterns) { + contributions.problemPatterns = rawPlugin.contributes!.problemPatterns as ProblemPatternContribution[]; + } + contributions.snippets = this.readSnippets(rawPlugin); return contributions; } diff --git a/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts b/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts index f01d6989cdd12..1f8d0cec77489 100644 --- a/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts @@ -26,6 +26,7 @@ import { KeybindingsContributionPointHandler } from './keybindings/keybindings-c import { MonacoSnippetSuggestProvider } from '@theia/monaco/lib/browser/monaco-snippet-suggest-provider'; import { PluginSharedStyle } from './plugin-shared-style'; import { CommandRegistry } from '@theia/core'; +import { TaskDefinitionRegistry, ProblemMatcherRegistry, ProblemPatternRegistry } from '@theia/task/lib/common'; @injectable() export class PluginContributionHandler { @@ -59,7 +60,16 @@ export class PluginContributionHandler { @inject(PluginSharedStyle) protected readonly style: PluginSharedStyle; - handleContributions(contributions: PluginContribution): void { + @inject(TaskDefinitionRegistry) + protected readonly taskDefinitionRegistry: TaskDefinitionRegistry; + + @inject(ProblemMatcherRegistry) + protected readonly problemMatcherRegistry: ProblemMatcherRegistry; + + @inject(ProblemPatternRegistry) + protected readonly problemPatternRegistry: ProblemPatternRegistry; + + handleContributions(contributions: PluginContribution, modelId: string): void { if (contributions.configuration) { this.updateConfigurationSchema(contributions.configuration); } @@ -149,6 +159,18 @@ export class PluginContributionHandler { }); } } + + if (contributions.taskDefinitions) { + contributions.taskDefinitions.forEach(def => this.taskDefinitionRegistry.register(def, modelId)); + } + + if (contributions.problemPatterns) { + contributions.problemPatterns.forEach(pattern => this.problemPatternRegistry.register(pattern.name!, pattern)); + } + + if (contributions.problemMatchers) { + contributions.problemMatchers.forEach(matcher => this.problemMatcherRegistry.register(matcher)); + } } protected registerCommands(contribution: PluginContribution): void { diff --git a/packages/plugin-ext/src/plugin/types-impl.ts b/packages/plugin-ext/src/plugin/types-impl.ts index 0910d9d1f82a5..446c4cb05ec02 100644 --- a/packages/plugin-ext/src/plugin/types-impl.ts +++ b/packages/plugin-ext/src/plugin/types-impl.ts @@ -1728,12 +1728,14 @@ export class Task { if (this.taskExecution instanceof ProcessExecution) { Object.assign(this.taskDefinition, { type: 'process', - id: this.taskExecution.computeId() + id: this.taskExecution.computeId(), + taskType: this.taskDefinition!.type }); } else if (this.taskExecution instanceof ShellExecution) { Object.assign(this.taskDefinition, { type: 'shell', - id: this.taskExecution.computeId() + id: this.taskExecution.computeId(), + taskType: this.taskDefinition!.type }); } } diff --git a/packages/process/src/node/terminal-process.ts b/packages/process/src/node/terminal-process.ts index fe663386006df..5b7d26b37c36d 100644 --- a/packages/process/src/node/terminal-process.ts +++ b/packages/process/src/node/terminal-process.ts @@ -286,5 +286,4 @@ export class TerminalProcess extends Process { throw new Error('pty process did not start correctly'); } } - } diff --git a/packages/task/package.json b/packages/task/package.json index 74a0cd42a7f1d..5de44be6a0f5b 100644 --- a/packages/task/package.json +++ b/packages/task/package.json @@ -10,7 +10,8 @@ "@theia/terminal": "^0.6.0", "@theia/variable-resolver": "^0.6.0", "@theia/workspace": "^0.6.0", - "jsonc-parser": "^2.0.2" + "jsonc-parser": "^2.0.2", + "vscode-uri": "^1.0.1" }, "publishConfig": { "access": "public" diff --git a/packages/task/src/browser/quick-open-task.ts b/packages/task/src/browser/quick-open-task.ts index 330481da74c55..55375c7a4a054 100644 --- a/packages/task/src/browser/quick-open-task.ts +++ b/packages/task/src/browser/quick-open-task.ts @@ -20,7 +20,7 @@ import { QuickOpenGroupItem, QuickOpenMode, QuickOpenHandler, QuickOpenOptions, QuickOpenActionProvider, QuickOpenGroupItemOptions } from '@theia/core/lib/browser/quick-open/'; import { TaskService } from './task-service'; -import { ContributedTaskConfiguration, TaskInfo, TaskConfiguration } from '../common/task-protocol'; +import { TaskInfo, TaskConfiguration, TaskDefinitionRegistry } from '../common/task-protocol'; import { TaskConfigurations } from './task-configurations'; import URI from '@theia/core/lib/common/uri'; import { TaskActionProvider } from './task-action-provider'; @@ -58,6 +58,9 @@ export class QuickOpenTask implements QuickOpenModel, QuickOpenHandler { @inject(TaskConfigurations) protected readonly taskConfigurations: TaskConfigurations; + @inject(TaskDefinitionRegistry) + protected readonly taskDefinitionRegistry: TaskDefinitionRegistry; + /** Initialize this quick open model with the tasks. */ async init(): Promise { const recentTasks = this.taskService.recentTasks; @@ -69,29 +72,38 @@ export class QuickOpenTask implements QuickOpenModel, QuickOpenHandler { const isMulti = stat ? !stat.isDirectory : false; this.items = []; this.items.push( - ...filteredRecentTasks.map((task, index) => - new TaskRunQuickOpenItem(task, this.taskService, isMulti, { + ...filteredRecentTasks.map((task, index) => { + const item = new TaskRunQuickOpenItem(task, this.taskService, isMulti, { groupLabel: index === 0 ? 'recently used tasks' : undefined, showBorder: false - })), - ...filteredConfiguredTasks.map((task, index) => - new TaskRunQuickOpenItem(task, this.taskService, isMulti, { + }); + item['taskDefinitionRegistry'] = this.taskDefinitionRegistry; + return item; + }), + ...filteredConfiguredTasks.map((task, index) => { + const item = new TaskRunQuickOpenItem(task, this.taskService, isMulti, { groupLabel: index === 0 ? 'configured tasks' : undefined, showBorder: ( filteredRecentTasks.length <= 0 ? false : index === 0 ? true : false ) - })), - ...filteredProvidedTasks.map((task, index) => - new TaskRunQuickOpenItem(task, this.taskService, isMulti, { + }); + item['taskDefinitionRegistry'] = this.taskDefinitionRegistry; + return item; + }), + ...filteredProvidedTasks.map((task, index) => { + const item = new TaskRunQuickOpenItem(task, this.taskService, isMulti, { groupLabel: index === 0 ? 'detected tasks' : undefined, showBorder: ( filteredRecentTasks.length <= 0 && filteredConfiguredTasks.length <= 0 ? false : index === 0 ? true : false ) - })) + }); + item['taskDefinitionRegistry'] = this.taskDefinitionRegistry; + return item; + }) ); this.actionProvider = this.items.length ? this.taskActionProvider : undefined; @@ -221,6 +233,8 @@ export class QuickOpenTask implements QuickOpenModel, QuickOpenHandler { export class TaskRunQuickOpenItem extends QuickOpenGroupItem { + protected taskDefinitionRegistry: TaskDefinitionRegistry; + constructor( protected readonly task: TaskConfiguration, protected taskService: TaskService, @@ -235,7 +249,7 @@ export class TaskRunQuickOpenItem extends QuickOpenGroupItem { } getLabel(): string { - if (ContributedTaskConfiguration.is(this.task)) { + if (this.taskDefinitionRegistry && !!this.taskDefinitionRegistry.getDefinition(this.task)) { return `${this.task._source}: ${this.task.label}`; } return `${this.task.type}: ${this.task.label}`; @@ -249,7 +263,7 @@ export class TaskRunQuickOpenItem extends QuickOpenGroupItem { if (!this.isMulti) { return ''; } - if (ContributedTaskConfiguration.is(this.task)) { + if (this.taskDefinitionRegistry && !!this.taskDefinitionRegistry.getDefinition(this.task)) { if (this.task._scope) { return new URI(this.task._scope).path.toString(); } @@ -265,12 +279,7 @@ export class TaskRunQuickOpenItem extends QuickOpenGroupItem { return false; } - if (ContributedTaskConfiguration.is(this.task)) { - this.taskService.run(this.task._source, this.task.label); - } else { - this.taskService.runConfiguredTask(this.task._source, this.task.label); - } - + this.taskService.run(this.task._source, this.task.label); return true; } } diff --git a/packages/task/src/browser/task-configurations.ts b/packages/task/src/browser/task-configurations.ts index 43e097cda2627..602dcab39a5f2 100644 --- a/packages/task/src/browser/task-configurations.ts +++ b/packages/task/src/browser/task-configurations.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { inject, injectable } from 'inversify'; -import { TaskConfiguration } from '../common/task-protocol'; +import { TaskConfiguration, TaskCustomization, TaskDefinitionRegistry, ContributedTaskConfiguration } from '../common'; import { Disposable, DisposableCollection, ResourceProvider } from '@theia/core/lib/common'; import URI from '@theia/core/lib/common/uri'; import { FileSystemWatcher, FileChangeEvent } from '@theia/filesystem/lib/browser/filesystem-watcher'; @@ -47,6 +47,8 @@ export class TaskConfigurations implements Disposable { * For the inner map (i.e., task config map), the key is task label and value TaskConfiguration */ protected tasksMap = new Map>(); + protected taskCustomizations: TaskCustomization[] = []; + protected watchedConfigFileUris: string[] = []; protected watchersMap = new Map(); // map of watchers for task config files, where the key is folder uri @@ -66,6 +68,9 @@ export class TaskConfigurations implements Disposable { @inject(OpenerService) protected readonly openerService: OpenerService; + @inject(TaskDefinitionRegistry) + protected readonly taskDefinitionRegistry: TaskDefinitionRegistry; + constructor( @inject(FileSystemWatcher) protected readonly watcherServer: FileSystemWatcher, @inject(FileSystem) protected readonly fileSystem: FileSystem @@ -173,6 +178,10 @@ export class TaskConfigurations implements Disposable { this.tasksMap.delete(source); } + getTaskCustomizations(type: string): TaskCustomization[] { + return this.taskCustomizations.filter(c => c.type === type); + } + /** returns the string uri of where the config file would be, if it existed under a given root directory */ protected getConfigFileUri(rootDir: string): string { return new URI(rootDir).resolve(this.TASKFILEPATH).resolve(this.TASKFILE).toString(); @@ -198,22 +207,38 @@ export class TaskConfigurations implements Disposable { * If reading a config file wasn't successful then does nothing. */ protected async refreshTasks(configFileUri: string) { - const tasksConfigsArray = await this.readTasks(configFileUri); - if (tasksConfigsArray) { + const tasksArray = await this.readTasks(configFileUri); + if (tasksArray) { + const configuredTasksArray: TaskConfiguration[] = []; + const customizations: TaskCustomization[] = []; + + tasksArray.forEach(t => { + if (this.isConfiguredTask(t)) { + customizations.push(t); + } else { + configuredTasksArray.push(t); + } + }); + // only clear tasks map when successful at parsing the config file // this way we avoid clearing and re-filling it multiple times if the // user is editing the file in the auto-save mode, having momentarily // non-parsing JSON. this.removeTasks(configFileUri); - if (tasksConfigsArray.length > 0) { + if (configuredTasksArray.length > 0) { const newTaskMap = new Map(); - for (const task of tasksConfigsArray) { + for (const task of configuredTasksArray) { newTaskMap.set(task.label, task); } const source = this.getSourceFolderFromConfigUri(configFileUri); this.tasksMap.set(source, newTaskMap); } + + if (customizations.length > 0) { + this.taskCustomizations.length = 0; + this.taskCustomizations = customizations; + } } } @@ -288,7 +313,7 @@ export class TaskConfigurations implements Disposable { protected filterDuplicates(tasks: TaskConfiguration[]): TaskConfiguration[] { const filteredTasks: TaskConfiguration[] = []; for (const task of tasks) { - if (filteredTasks.some(t => t.label === task.label)) { + if (filteredTasks.some(t => !this.isConfiguredTask(t) && t.label === task.label)) { // TODO: create a problem marker so that this issue will be visible in the editor? console.error(`Error parsing ${this.TASKFILE}: found duplicate entry for label: ${task.label}`); } else { @@ -301,4 +326,10 @@ export class TaskConfigurations implements Disposable { private getSourceFolderFromConfigUri(configFileUri: string): string { return new URI(configFileUri).parent.parent.path.toString(); } + + private isConfiguredTask(task: TaskConfiguration): task is ContributedTaskConfiguration { + const taskDefinition = this.taskDefinitionRegistry.getDefinition(task); + // it is considered as a customization if the task definition registry finds a def for the task configuration + return !!taskDefinition; + } } diff --git a/packages/task/src/browser/task-definition-registry.spec.ts b/packages/task/src/browser/task-definition-registry.spec.ts new file mode 100644 index 0000000000000..c6f287309f3a5 --- /dev/null +++ b/packages/task/src/browser/task-definition-registry.spec.ts @@ -0,0 +1,120 @@ +/******************************************************************************** + * Copyright (C) 2019 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { expect } from 'chai'; +import { TaskDefinitionRegistryImpl } from './task-definition-registry'; + +// tslint:disable:no-unused-expression +describe('TaskDefinitionRegistry', () => { + let registry: TaskDefinitionRegistryImpl; + const definitonContributionA = { + type: 'extA', + required: ['extensionType'], + properties: { + extensionType: { + type: 'string', + description: 'type or name of the extension / plugin' + }, + taskLabel: { + type: 'string', + description: 'label of the defined task' + } + } + }; + const definitonContributionB = { + type: 'extA', + required: ['extensionType', 'taskLabel', 'taskDetailedLabel'], + properties: { + extensionType: { + type: 'string', + description: 'type or name of the extension / plugin' + }, + taskLabel: { + type: 'string', + description: 'label 1 of the defined task' + }, + taskDetailedLabel: { + type: 'string', + description: 'label 2 of the defined task' + } + } + }; + + beforeEach(() => { + registry = new TaskDefinitionRegistryImpl(); + }); + + describe('register function', () => { + it('should transform the task definition contribution and store it in memory', () => { + const pluginId = 'plugin id'; + registry.register(definitonContributionA, pluginId); + expect(registry['definitions'].get(definitonContributionA.type)).to.be.ok; + expect(registry['definitions'].get(definitonContributionA.type)![0]).to.deep.equal({ + id: pluginId, + taskType: definitonContributionA.type, + properties: { + required: definitonContributionA.required, + all: Object.keys(definitonContributionA.properties) + } + }); + }); + }); + + describe('getDefinitions function', () => { + it('should return all definitions associated with the given type', () => { + const pluginIdA = 'plugin id a'; + registry.register(definitonContributionA, pluginIdA); + const defs1 = registry.getDefinitions(definitonContributionA.type); + expect(defs1.length).to.eq(1); + + const pluginIdB = 'plugin id b'; + registry.register(definitonContributionB, pluginIdB); + const defs2 = registry.getDefinitions(definitonContributionA.type); + expect(defs2.length).to.eq(2); + }); + }); + + describe('getDefinition function', () => { + it('should return undefined if the given task configuration does not match any registered definitions', () => { + const pluginIdA = 'plugin id a'; + registry.register(definitonContributionA, pluginIdA); + const pluginIdB = 'plugin id b'; + registry.register(definitonContributionB, pluginIdB); + const defs = registry.getDefinition({ + type: definitonContributionA.type, label: 'grunt task', task: 'build' + }); + expect(defs).to.be.not.ok; + }); + + it('should return the best match if there is one or more registered definitions match the given task configuration', () => { + const pluginIdA = 'plugin id a'; + registry.register(definitonContributionA, pluginIdA); + const pluginIdB = 'plugin id b'; + registry.register(definitonContributionB, pluginIdB); + const defs = registry.getDefinition({ + type: definitonContributionA.type, label: 'extention task', extensionType: 'extensionType', taskLabel: 'taskLabel' + }); + expect(defs).to.be.ok; + expect(defs!.id).to.be.eq(pluginIdA); + + const defs2 = registry.getDefinition({ + type: definitonContributionA.type, label: 'extention task', extensionType: 'extensionType', taskLabel: 'taskLabel', taskDetailedLabel: 'taskDetailedLabel' + }); + expect(defs2).to.be.ok; + expect(defs2!.id).to.be.eq(pluginIdB); + }); + }); +}); diff --git a/packages/task/src/browser/task-definition-registry.ts b/packages/task/src/browser/task-definition-registry.ts new file mode 100644 index 0000000000000..06d5e3883f17b --- /dev/null +++ b/packages/task/src/browser/task-definition-registry.ts @@ -0,0 +1,65 @@ +/******************************************************************************** + * Copyright (C) 2019 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable } from 'inversify'; +import { + TaskDefinition, TaskConfiguration, TaskDefinitionRegistry, TaskDefinitionContribution +} from '../common'; + +@injectable() +export class TaskDefinitionRegistryImpl implements TaskDefinitionRegistry { + + // task type - array of task definitions + private definitions: Map = new Map(); + + getDefinitions(taskType: string): TaskDefinition[] { + return this.definitions.get(taskType) || []; + } + + getDefinition(taskConfiguration: TaskConfiguration): TaskDefinition | undefined { + const definitions = this.getDefinitions(taskConfiguration.taskType || taskConfiguration.type); + let matchedDefinition: TaskDefinition | undefined; + let highest = -1; + for (const def of definitions) { + let score = 0; + if (!def.properties.required.every(requiredProp => taskConfiguration[requiredProp] !== undefined)) { + continue; + } + score += def.properties.required.length; // number of required properties + const requiredProps = new Set(def.properties.required); + // number of optional properties + score += def.properties.all.filter(p => !requiredProps.has(p) && taskConfiguration[p] !== undefined).length; + if (score > highest) { + highest = score; + matchedDefinition = def; + } + } + return matchedDefinition; + } + + register(definitionContribution: TaskDefinitionContribution, pluginId: string): void { + const definition = { + id: pluginId, + taskType: definitionContribution.type, + properties: { + required: definitionContribution.required, + all: Object.keys(definitionContribution.properties) + } + }; + const taskType = definition.taskType; + this.definitions.set(taskType, [...this.getDefinitions(taskType), definition]); + } +} diff --git a/packages/task/src/browser/task-frontend-module.ts b/packages/task/src/browser/task-frontend-module.ts index 8e95eba0efddc..483a472c7600f 100644 --- a/packages/task/src/browser/task-frontend-module.ts +++ b/packages/task/src/browser/task-frontend-module.ts @@ -25,11 +25,14 @@ import { TaskConfigurations } from './task-configurations'; import { ProvidedTaskConfigurations } from './provided-task-configurations'; import { TaskFrontendContribution } from './task-frontend-contribution'; import { createCommonBindings } from '../common/task-common-module'; -import { TaskServer, taskPath } from '../common/task-protocol'; +import { ProblemMatcherRegistry, ProblemPatternRegistry, TaskServer, TaskDefinitionRegistry, taskPath } from '../common/task-protocol'; import { TaskWatcher } from '../common/task-watcher'; import { bindProcessTaskModule } from './process/process-task-frontend-module'; import { TaskSchemaUpdater } from './task-schema-updater'; import { TaskActionProvider, ConfigureTaskAction } from './task-action-provider'; +import { TaskDefinitionRegistryImpl } from './task-definition-registry'; +import { ProblemMatcherRegistryImpl } from './task-problem-matcher-registry'; +import { ProblemPatternRegistryImpl } from './task-problem-pattern-registry'; import '../../src/browser/style/index.css'; export default new ContainerModule(bind => { @@ -52,6 +55,10 @@ export default new ContainerModule(bind => { return connection.createProxy(taskPath, taskWatcher.getTaskClient()); }).inSingletonScope(); + bind(TaskDefinitionRegistry).to(TaskDefinitionRegistryImpl).inSingletonScope(); + bind(ProblemMatcherRegistry).to(ProblemMatcherRegistryImpl).inSingletonScope(); + bind(ProblemPatternRegistry).to(ProblemPatternRegistryImpl).inSingletonScope(); + createCommonBindings(bind); bind(TaskProviderRegistry).toSelf().inSingletonScope(); diff --git a/packages/task/src/browser/task-problem-matcher-registry.ts b/packages/task/src/browser/task-problem-matcher-registry.ts new file mode 100644 index 0000000000000..795e10317334d --- /dev/null +++ b/packages/task/src/browser/task-problem-matcher-registry.ts @@ -0,0 +1,219 @@ +/******************************************************************************** + * Copyright (C) 2019 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { inject, injectable, postConstruct } from 'inversify'; +import { + ApplyToKind, FileLocationKind, NamedProblemMatcher, Severity, + ProblemMatcherRegistry, ProblemPattern, ProblemPatternRegistry, + ProblemMatcher, ProblemMatcherContribution, WatchingMatcher +} from '../common'; + +@injectable() +export class ProblemMatcherRegistryImpl implements ProblemMatcherRegistry { + + private matchers: { [name: string]: NamedProblemMatcher }; + private readyPromise: Promise; + + @inject(ProblemPatternRegistry) + protected readonly problemPatternRegistry: ProblemPatternRegistry; + + @postConstruct() + protected init() { + // tslint:disable-next-line:no-null-keyword + this.matchers = Object.create(null); + this.problemPatternRegistry.onReady().then(() => { + this.fillDefaults(); + this.readyPromise = new Promise((res, rej) => res(undefined)); + }); + } + + onReady(): Promise { + return this.readyPromise; + } + + async register(matcher: ProblemMatcherContribution): Promise { + if (!matcher.name) { + console.error('Only named Problem Matchers can be registered.'); + return; + } + const problemMatcher = await this.getProblemMatcherFromContribution(matcher); + this.add(problemMatcher as NamedProblemMatcher); + } + + get(name: string): NamedProblemMatcher | undefined { + if (name.startsWith('$')) { + return this.matchers[name.slice(1)]; + } + return this.matchers[name]; + } + + async getProblemMatcherFromContribution(matcher: ProblemMatcherContribution): Promise { + const { fileLocation, filePrefix } = this.getFileLocationKindAndPrefix(matcher); + const patterns: ProblemPattern[] = []; + if (matcher.pattern) { + if (typeof matcher.pattern === 'string') { + await this.problemPatternRegistry.onReady(); + const registeredPattern = this.problemPatternRegistry.get(matcher.pattern); + if (Array.isArray(registeredPattern)) { + patterns.push(...registeredPattern); + } else if (!!registeredPattern) { + patterns.push(registeredPattern); + } + } else if (Array.isArray(matcher.pattern)) { + patterns.push(...matcher.pattern.map(p => ProblemPattern.fromProblemPatternContribution(p))); + } else { + patterns.push(ProblemPattern.fromProblemPatternContribution(matcher.pattern)); + } + } + const problemMatcher = { + name: matcher.name, + label: matcher.label, + deprecated: matcher.deprecated, + owner: matcher.owner, + source: matcher.source, + applyTo: ApplyToKind.fromString(matcher.applyTo) || ApplyToKind.allDocuments, + fileLocation, + filePrefix, + pattern: patterns, + severity: Severity.fromValue(matcher.severity), + watching: WatchingMatcher.fromWatchingMatcherContribution(matcher.background || matcher.watching) + }; + return problemMatcher; + } + + private add(matcher: NamedProblemMatcher): void { + this.matchers[matcher.name] = matcher; + } + + private getFileLocationKindAndPrefix(matcher: ProblemMatcherContribution): { fileLocation: FileLocationKind, filePrefix: string } { + let fileLocation = FileLocationKind.Relative; + let filePrefix = '${workspaceFolder}'; + if (matcher.fileLocation !== undefined) { + if (Array.isArray(matcher.fileLocation)) { + if (matcher.fileLocation.length > 0) { + const locationKind = FileLocationKind.fromString(matcher.fileLocation[0]); + if (matcher.fileLocation.length === 1 && locationKind === FileLocationKind.Absolute) { + fileLocation = locationKind; + } else if (matcher.fileLocation.length === 2 && locationKind === FileLocationKind.Relative && matcher.fileLocation[1]) { + fileLocation = locationKind; + filePrefix = matcher.fileLocation[1]; + } + } + } else { + const locationKind = FileLocationKind.fromString(matcher.fileLocation); + if (locationKind) { + fileLocation = locationKind; + if (locationKind === FileLocationKind.Relative) { + filePrefix = '${workspaceFolder}'; + } + } + } + } + return { fileLocation, filePrefix }; + } + + // copid from https://github.com/Microsoft/vscode/blob/1.33.1/src/vs/workbench/contrib/tasks/common/problemMatcher.ts + private fillDefaults(): void { + this.add({ + name: 'msCompile', + label: 'Microsoft compiler problems', + owner: 'msCompile', + applyTo: ApplyToKind.allDocuments, + fileLocation: FileLocationKind.Absolute, + pattern: (this.problemPatternRegistry.get('msCompile'))! + }); + + this.add({ + name: 'lessCompile', + label: 'Less problems', + deprecated: true, + owner: 'lessCompile', + source: 'less', + applyTo: ApplyToKind.allDocuments, + fileLocation: FileLocationKind.Absolute, + pattern: (this.problemPatternRegistry.get('lessCompile'))!, + severity: Severity.Error + }); + + this.add({ + name: 'gulp-tsc', + label: 'Gulp TSC Problems', + owner: 'typescript', + source: 'ts', + applyTo: ApplyToKind.closedDocuments, + fileLocation: FileLocationKind.Relative, + filePrefix: '${workspaceFolder}', + pattern: (this.problemPatternRegistry.get('gulp-tsc'))! + }); + + this.add({ + name: 'jshint', + label: 'JSHint problems', + owner: 'jshint', + source: 'jshint', + applyTo: ApplyToKind.allDocuments, + fileLocation: FileLocationKind.Absolute, + pattern: (this.problemPatternRegistry.get('jshint'))! + }); + + this.add({ + name: 'jshint-stylish', + label: 'JSHint stylish problems', + owner: 'jshint', + source: 'jshint', + applyTo: ApplyToKind.allDocuments, + fileLocation: FileLocationKind.Absolute, + pattern: (this.problemPatternRegistry.get('jshint-stylish'))! + }); + + this.add({ + name: 'eslint-compact', + label: 'ESLint compact problems', + owner: 'eslint', + source: 'eslint', + applyTo: ApplyToKind.allDocuments, + fileLocation: FileLocationKind.Absolute, + filePrefix: '${workspaceFolder}', + pattern: (this.problemPatternRegistry.get('eslint-compact'))! + }); + + this.add({ + name: 'eslint-stylish', + label: 'ESLint stylish problems', + owner: 'eslint', + source: 'eslint', + applyTo: ApplyToKind.allDocuments, + fileLocation: FileLocationKind.Absolute, + pattern: (this.problemPatternRegistry.get('eslint-stylish'))! + }); + + this.add({ + name: 'go', + label: 'Go problems', + owner: 'go', + source: 'go', + applyTo: ApplyToKind.allDocuments, + fileLocation: FileLocationKind.Relative, + filePrefix: '${workspaceFolder}', + pattern: (this.problemPatternRegistry.get('go'))! + }); + } +} diff --git a/packages/task/src/browser/task-problem-pattern-registry.ts b/packages/task/src/browser/task-problem-pattern-registry.ts new file mode 100644 index 0000000000000..772e63d484ca4 --- /dev/null +++ b/packages/task/src/browser/task-problem-pattern-registry.ts @@ -0,0 +1,177 @@ +/******************************************************************************** + * Copyright (C) 2019 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { injectable, postConstruct } from 'inversify'; +import { ProblemLocationKind, ProblemPattern, ProblemPatternRegistry, ProblemPatternContribution } from '../common'; + +@injectable() +export class ProblemPatternRegistryImpl implements ProblemPatternRegistry { + private patterns: { [name: string]: ProblemPattern | ProblemPattern[] }; + private readyPromise: Promise; + + @postConstruct() + protected init() { + // tslint:disable-next-line:no-null-keyword + this.patterns = Object.create(null); + this.fillDefaults(); + this.readyPromise = new Promise((res, rej) => res(undefined)); + } + + onReady(): Promise { + return this.readyPromise; + } + + register(key: string, value: ProblemPatternContribution | ProblemPatternContribution[]): void { + if (Array.isArray(value)) { + value.forEach(problemPatternContribution => this.register(key, problemPatternContribution)); + } else { + if (!value.name) { + console.error('Only named Problem Patterns can be registered.'); + return; + } + const problemPattern = ProblemPattern.fromProblemPatternContribution(value); + this.add(key, problemPattern); + } + } + + get(key: string): undefined | ProblemPattern | ProblemPattern[] { + return this.patterns[key]; + } + + private add(key: string, value: ProblemPattern | ProblemPattern[]): void { + this.patterns[key] = value; + } + + // copid from https://github.com/Microsoft/vscode/blob/1.33.1/src/vs/workbench/contrib/tasks/common/problemMatcher.ts + private fillDefaults(): void { + this.add('msCompile', { + regexp: /^(?:\s+\d+\>)?([^\s].*)\((\d+|\d+,\d+|\d+,\d+,\d+,\d+)\)\s*:\s+(error|warning|info)\s+(\w{1,2}\d+)\s*:\s*(.*)$/.source, + kind: ProblemLocationKind.Location, + file: 1, + location: 2, + severity: 3, + code: 4, + message: 5 + }); + this.add('gulp-tsc', { + regexp: /^([^\s].*)\((\d+|\d+,\d+|\d+,\d+,\d+,\d+)\):\s+(\d+)\s+(.*)$/.source, + kind: ProblemLocationKind.Location, + file: 1, + location: 2, + code: 3, + message: 4 + }); + this.add('cpp', { + regexp: /^([^\s].*)\((\d+|\d+,\d+|\d+,\d+,\d+,\d+)\):\s+(error|warning|info)\s+(C\d+)\s*:\s*(.*)$/.source, + kind: ProblemLocationKind.Location, + file: 1, + location: 2, + severity: 3, + code: 4, + message: 5 + }); + this.add('csc', { + regexp: /^([^\s].*)\((\d+|\d+,\d+|\d+,\d+,\d+,\d+)\):\s+(error|warning|info)\s+(CS\d+)\s*:\s*(.*)$/.source, + kind: ProblemLocationKind.Location, + file: 1, + location: 2, + severity: 3, + code: 4, + message: 5 + }); + this.add('vb', { + regexp: /^([^\s].*)\((\d+|\d+,\d+|\d+,\d+,\d+,\d+)\):\s+(error|warning|info)\s+(BC\d+)\s*:\s*(.*)$/.source, + kind: ProblemLocationKind.Location, + file: 1, + location: 2, + severity: 3, + code: 4, + message: 5 + }); + this.add('lessCompile', { + regexp: /^\s*(.*) in file (.*) line no. (\d+)$/.source, + kind: ProblemLocationKind.Location, + message: 1, + file: 2, + line: 3 + }); + this.add('jshint', { + regexp: /^(.*):\s+line\s+(\d+),\s+col\s+(\d+),\s(.+?)(?:\s+\((\w)(\d+)\))?$/.source, + kind: ProblemLocationKind.Location, + file: 1, + line: 2, + character: 3, + message: 4, + severity: 5, + code: 6 + }); + this.add('jshint-stylish', [ + { + regexp: /^(.+)$/.source, + kind: ProblemLocationKind.Location, + file: 1 + }, + { + regexp: /^\s+line\s+(\d+)\s+col\s+(\d+)\s+(.+?)(?:\s+\((\w)(\d+)\))?$/.source, + line: 1, + character: 2, + message: 3, + severity: 4, + code: 5, + loop: true + } + ]); + this.add('eslint-compact', { + regexp: /^(.+):\sline\s(\d+),\scol\s(\d+),\s(Error|Warning|Info)\s-\s(.+)\s\((.+)\)$/.source, + file: 1, + kind: ProblemLocationKind.Location, + line: 2, + character: 3, + severity: 4, + message: 5, + code: 6 + }); + this.add('eslint-stylish', [ + { + regexp: /^([^\s].*)$/.source, + kind: ProblemLocationKind.Location, + file: 1 + }, + { + regexp: /^\s+(\d+):(\d+)\s+(error|warning|info)\s+(.+?)(?:\s\s+(.*))?$/.source, + line: 1, + character: 2, + severity: 3, + message: 4, + code: 5, + loop: true + } + ]); + this.add('go', { + regexp: /^([^:]*: )?((.:)?[^:]*):(\d+)(:(\d+))?: (.*)$/.source, + kind: ProblemLocationKind.Location, + file: 2, + line: 4, + character: 6, + message: 7 + }); + } +} diff --git a/packages/task/src/browser/task-service.ts b/packages/task/src/browser/task-service.ts index 0b9dfc4198af4..f683463a7f154 100644 --- a/packages/task/src/browser/task-service.ts +++ b/packages/task/src/browser/task-service.ts @@ -23,9 +23,23 @@ import { TERMINAL_WIDGET_FACTORY_ID, TerminalWidgetFactoryOptions } from '@theia import { TerminalService } from '@theia/terminal/lib/browser/base/terminal-service'; import { TerminalWidget } from '@theia/terminal/lib/browser/base/terminal-widget'; import { MessageService } from '@theia/core/lib/common/message-service'; -import { TaskServer, TaskExitedEvent, TaskInfo, TaskConfiguration } from '../common/task-protocol'; +import { ProblemManager } from '@theia/markers/lib/browser/problem/problem-manager'; import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { VariableResolverService } from '@theia/variable-resolver/lib/browser'; +import { + ProblemMatcher, + ProblemMatchData, + ProblemMatcherContribution, + ProblemMatcherRegistry, + TaskServer, + TaskExitedEvent, + TaskInfo, + TaskConfiguration, + TaskCustomization, + TaskDefinitionRegistry, + TaskOutputProcessedEvent, + RunTaskOption +} from '../common'; import { TaskWatcher } from '../common/task-watcher'; import { TaskConfigurationClient, TaskConfigurations } from './task-configurations'; import { ProvidedTaskConfigurations } from './provided-task-configurations'; @@ -88,6 +102,15 @@ export class TaskService implements TaskConfigurationClient { @inject(EditorManager) protected readonly editorManager: EditorManager; + @inject(ProblemManager) + protected readonly problemManager: ProblemManager; + + @inject(TaskDefinitionRegistry) + protected readonly taskDefinitionRegistry: TaskDefinitionRegistry; + + @inject(ProblemMatcherRegistry) + protected readonly problemMatcherRegistry: ProblemMatcherRegistry; + /** * @deprecated To be removed in 0.5.0 */ @@ -115,6 +138,35 @@ export class TaskService implements TaskConfigurationClient { } }); + this.taskWatcher.onOutputProcessed((event: TaskOutputProcessedEvent) => { + if (!this.isEventForThisClient(event.ctx)) { + return; + } + if (event.problems) { + event.problems.forEach(problem => { + const existingMarkers = this.problemManager.findMarkers({ owner: problem.description.owner }); + const uris = new Set(); + existingMarkers.forEach(marker => uris.add(marker.uri)); + if (ProblemMatchData.is(problem) && problem.resource) { + const uri = new URI(problem.resource.path).withScheme(problem.resource.scheme); + if (uris.has(uri.toString())) { + const newData = [ + ...existingMarkers + .filter(marker => marker.uri === uri.toString()) + .map(markerData => markerData.data), + problem.marker + ]; + this.problemManager.setMarkers(uri, problem.description.owner, newData); + } else { + this.problemManager.setMarkers(uri, problem.description.owner, [problem.marker]); + } + } else { // should have received an event for finding the "background task begins" pattern + uris.forEach(uriString => this.problemManager.setMarkers(new URI(uriString), problem.description.owner, [])); + } + }); + } + }); + // notify user that task has finished this.taskWatcher.onTaskExit((event: TaskExitedEvent) => { if (!this.isEventForThisClient(event.ctx)) { @@ -218,7 +270,7 @@ export class TaskService implements TaskConfigurationClient { return; } - this.runTask(task); + this.run(source, taskLabel); } /** @@ -238,18 +290,51 @@ export class TaskService implements TaskConfigurationClient { */ async run(source: string, taskLabel: string): Promise { let task = await this.getProvidedTask(source, taskLabel); - if (!task) { + const matchers: (string | ProblemMatcherContribution)[] = []; + if (!task) { // if a provided task cannot be found, search from tasks.json task = this.taskConfigurations.getTask(source, taskLabel); if (!task) { this.logger.error(`Can't get task launch configuration for label: ${taskLabel}`); return; + } else if (task.problemMatcher) { + if (Array.isArray(task.problemMatcher)) { + matchers.push(...task.problemMatcher); + } else { + matchers.push(task.problemMatcher); + } } + } else { // if a provided task is found, check if it is customized in tasks.json + const taskType = task.taskType || task.type; + const customizations = this.taskConfigurations.getTaskCustomizations(taskType); + const matcherContributions = this.getProblemMatchers(task, customizations); + matchers.push(...matcherContributions); } - - this.runTask(task); + await this.problemMatcherRegistry.onReady(); + const resolvedMatchers: ProblemMatcher[] = []; + // resolve matchers before passing them to the server + for (const matcher of matchers) { + let resolvedMatcher: ProblemMatcher | undefined; + if (typeof matcher === 'string') { + resolvedMatcher = this.problemMatcherRegistry.get(matcher); + } else { + resolvedMatcher = await this.problemMatcherRegistry.getProblemMatcherFromContribution(matcher); + } + if (resolvedMatcher) { + const scope = task._scope || task._source; + if (resolvedMatcher.filePrefix && scope) { + const options = { context: new URI(scope).withScheme('file') }; + const resolvedPrefix = await this.variableResolverService.resolve(resolvedMatcher.filePrefix, options); + Object.assign(resolvedMatcher, { filePrefix: resolvedPrefix }); + } + resolvedMatchers.push(resolvedMatcher); + } + } + this.runTask(task, { + customization: { type: task.taskType || task.type, problemMatcher: resolvedMatchers } + }); } - async runTask(task: TaskConfiguration): Promise { + async runTask(task: TaskConfiguration, option?: RunTaskOption): Promise { const source = task._source; const taskLabel = task.label; @@ -264,9 +349,11 @@ export class TaskService implements TaskConfigurationClient { return; } + await this.removeProblemMarks(option); + let taskInfo: TaskInfo; try { - taskInfo = await this.taskServer.run(resolvedTask, this.getContext()); + taskInfo = await this.taskServer.run(resolvedTask, this.getContext(), option); this.lastTask = { source, taskLabel }; } catch (error) { const errorStr = `Error launching task '${taskLabel}': ${error.message}`; @@ -283,6 +370,41 @@ export class TaskService implements TaskConfigurationClient { } } + private getProblemMatchers(taskConfiguration: TaskConfiguration, customizations: TaskCustomization[]): (string | ProblemMatcherContribution)[] { + const hasCustomization = customizations.length > 0; + const problemMatchers: (string | ProblemMatcherContribution)[] = []; + if (hasCustomization) { + const taskDefinition = this.taskDefinitionRegistry.getDefinition(taskConfiguration); + if (taskDefinition) { + const cus = customizations.filter(customization => + taskDefinition.properties.required.every(rp => customization[rp] === taskConfiguration[rp]) + )[0]; // Only support having one customization per task + if (cus && cus.problemMatcher) { + if (Array.isArray(cus.problemMatcher)) { + problemMatchers.push(...cus.problemMatcher); + } else { + problemMatchers.push(cus.problemMatcher); + } + } + } + } + return problemMatchers; + } + + private async removeProblemMarks(option?: RunTaskOption): Promise { + if (option && option.customization) { + const matchersFromOption = option.customization.problemMatcher || []; + for (const matcher of matchersFromOption) { + if (matcher && matcher.owner) { + const existingMarkers = this.problemManager.findMarkers({ owner: matcher.owner }); + const uris = new Set(); + existingMarkers.forEach(marker => uris.add(marker.uri)); + uris.forEach(uriString => this.problemManager.setMarkers(new URI(uriString), matcher.owner, [])); + } + } + } + } + /** * Run selected text in the last active terminal. */ diff --git a/packages/task/src/common/index.ts b/packages/task/src/common/index.ts index a1793c24678b5..ded0cd512dad3 100644 --- a/packages/task/src/common/index.ts +++ b/packages/task/src/common/index.ts @@ -16,3 +16,4 @@ export * from './task-protocol'; export * from './task-watcher'; +export * from './problem-matcher-protocol'; diff --git a/packages/task/src/common/problem-matcher-protocol.ts b/packages/task/src/common/problem-matcher-protocol.ts new file mode 100644 index 0000000000000..4506ae62ef507 --- /dev/null +++ b/packages/task/src/common/problem-matcher-protocol.ts @@ -0,0 +1,248 @@ +/******************************************************************************** + * Copyright (C) 2019 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +// This file is inspired by VSCode https://github.com/Microsoft/vscode/blob/1.33.1/src/vs/workbench/contrib/tasks/common/problemMatcher.ts +// 'problemMatcher.ts' copyright: +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver-types'; +import vscodeURI from 'vscode-uri/lib/umd'; +import { ProblemPatternContribution, WatchingMatcherContribution } from './task-protocol'; + +export enum ApplyToKind { + allDocuments, + openDocuments, + closedDocuments +} + +export namespace ApplyToKind { + export function fromString(value: string): ApplyToKind | undefined { + value = value.toLowerCase(); + if (value === 'alldocuments') { + return ApplyToKind.allDocuments; + } else if (value === 'opendocuments') { + return ApplyToKind.openDocuments; + } else if (value === 'closeddocuments') { + return ApplyToKind.closedDocuments; + } else { + return undefined; + } + } +} + +export enum FileLocationKind { + Auto, + Relative, + Absolute +} + +export namespace FileLocationKind { + export function fromString(value: string): FileLocationKind | undefined { + value = value.toLowerCase(); + if (value === 'absolute') { + return FileLocationKind.Absolute; + } else if (value === 'relative') { + return FileLocationKind.Relative; + } else { + return undefined; + } + } +} + +export enum Severity { + Ignore = 0, + Info = 1, + Warning = 2, + Error = 3 +} + +export namespace Severity { + + const _error = 'error'; + const _warning = 'warning'; + const _warn = 'warn'; + const _info = 'info'; + + // Parses 'error', 'warning', 'warn', 'info' in call casings and falls back to ignore. + export function fromValue(value: string | undefined): Severity { + if (!value) { + return Severity.Ignore; + } + + if (value.toLowerCase() === _error) { + return Severity.Error; + } + + if (value.toLowerCase() === _warning || value.toLowerCase() === _warn) { + return Severity.Warning; + } + + if (value.toLowerCase() === _info) { + return Severity.Info; + } + return Severity.Ignore; + } + + export function toDiagnosticSeverity(value: Severity): DiagnosticSeverity { + switch (value) { + case Severity.Ignore: + return DiagnosticSeverity.Hint; + case Severity.Info: + return DiagnosticSeverity.Information; + case Severity.Warning: + return DiagnosticSeverity.Warning; + case Severity.Error: + return DiagnosticSeverity.Error; + default: + return DiagnosticSeverity.Error; + } + } +} + +export interface WatchingPattern { + regexp: string; + file?: number; +} + +export interface WatchingMatcher { + // If set to true the background monitor is in active mode when the task starts. + // This is equals of issuing a line that matches the beginPattern + activeOnStart: boolean; + beginsPattern: WatchingPattern; + endsPattern: WatchingPattern; +} +export namespace WatchingMatcher { + export function fromWatchingMatcherContribution(value: WatchingMatcherContribution | undefined): WatchingMatcher | undefined { + if (!value) { + return undefined; + } + return { + activeOnStart: !!value.activeOnStart, + beginsPattern: typeof value.beginsPattern === 'string' ? { regexp: value.beginsPattern } : value.beginsPattern, + endsPattern: typeof value.endsPattern === 'string' ? { regexp: value.endsPattern } : value.endsPattern + }; + } +} + +export enum ProblemLocationKind { + File, + Location +} + +export namespace ProblemLocationKind { + export function fromString(value: string): ProblemLocationKind | undefined { + value = value.toLowerCase(); + if (value === 'file') { + return ProblemLocationKind.File; + } else if (value === 'location') { + return ProblemLocationKind.Location; + } else { + return undefined; + } + } +} + +export interface ProblemMatcher { + deprecated?: boolean; + + owner: string; + source?: string; + applyTo: ApplyToKind; + fileLocation: FileLocationKind; + filePrefix?: string; + pattern: ProblemPattern | ProblemPattern[]; + severity?: Severity; + watching?: WatchingMatcher; + uriProvider?: (path: string) => vscodeURI; +} + +export interface NamedProblemMatcher extends ProblemMatcher { + name: string; + label: string; +} + +export namespace ProblemMatcher { + export function isWatchModeWatcher(matcher: ProblemMatcher): boolean { + return !!matcher.watching; + } +} + +export interface ProblemPattern { + name?: string; + + regexp: string; + + kind?: ProblemLocationKind; + file?: number; + message?: number; + location?: number; + line?: number; + character?: number; + endLine?: number; + endCharacter?: number; + code?: number; + severity?: number; + loop?: boolean; +} +export namespace ProblemPattern { + export function fromProblemPatternContribution(value: ProblemPatternContribution): ProblemPattern { + return { + name: value.name, + regexp: value.regexp, + kind: value.kind ? ProblemLocationKind.fromString(value.kind) : undefined, + file: value.file, + message: value.message, + location: value.location, + line: value.line, + character: value.character, + endLine: value.endLine, + endCharacter: value.endCharacter, + code: value.code, + severity: value.severity, + loop: value.loop + }; + } +} + +export interface ProblemData { + kind?: ProblemLocationKind; + file?: string; + location?: string; + line?: string; + character?: string; + endLine?: string; + endCharacter?: string; + message?: string; + severity?: string; + code?: string; +} + +export interface ProblemMatch { + resource?: vscodeURI; + description: ProblemMatcher; +} + +export interface ProblemMatchData extends ProblemMatch { + marker: Diagnostic; +} +export namespace ProblemMatchData { + export function is(data: ProblemMatch): data is ProblemMatchData { + return 'marker' in data; + } +} diff --git a/packages/task/src/common/task-protocol.ts b/packages/task/src/common/task-protocol.ts index 19b1b6ffa4bc4..3ae6725c0e56c 100644 --- a/packages/task/src/common/task-protocol.ts +++ b/packages/task/src/common/task-protocol.ts @@ -15,19 +15,23 @@ ********************************************************************************/ import { JsonRpcServer } from '@theia/core/lib/common/messaging/proxy-factory'; +import { ProblemMatcher, ProblemPattern, NamedProblemMatcher, ProblemMatch, WatchingPattern } from './problem-matcher-protocol'; export const taskPath = '/services/task'; export const TaskServer = Symbol('TaskServer'); export const TaskClient = Symbol('TaskClient'); -export interface TaskConfiguration { +export interface TaskCustomization { + type: string; + problemMatcher?: string | ProblemMatcherContribution | (string | ProblemMatcherContribution)[]; + // tslint:disable-next-line:no-any + [name: string]: any; +} + +export interface TaskConfiguration extends TaskCustomization { /** A label that uniquely identifies a task configuration per source */ readonly label: string; - readonly type: string; - /** Additional task type specific properties. */ - // tslint:disable-next-line:no-any - readonly [key: string]: any; } export namespace TaskConfiguration { export function equals(one: TaskConfiguration, other: TaskConfiguration): boolean { @@ -48,11 +52,6 @@ export interface ContributedTaskConfiguration extends TaskConfiguration { */ readonly _scope: string | undefined; } -export namespace ContributedTaskConfiguration { - export function is(config: TaskConfiguration | undefined): config is ContributedTaskConfiguration { - return !!config && '_source' in config && '_scope' in config; - } -} /** Runtime information about Task. */ export interface TaskInfo { @@ -71,7 +70,7 @@ export interface TaskInfo { export interface TaskServer extends JsonRpcServer { /** Run a task. Optionally pass a context. */ - run(task: TaskConfiguration, ctx?: string): Promise; + run(task: TaskConfiguration, ctx?: string, option?: RunTaskOption): Promise; /** Kill a task, by id. */ kill(taskId: number): Promise; /** @@ -89,6 +88,17 @@ export interface TaskServer extends JsonRpcServer { } +export interface TaskCustomizationData { + type: string; + problemMatcher?: ProblemMatcher[]; + // tslint:disable-next-line:no-any + [name: string]: any; +} + +export interface RunTaskOption { + customization?: TaskCustomizationData; +} + /** Event sent when a task has concluded its execution */ export interface TaskExitedEvent { readonly taskId: number; @@ -99,7 +109,105 @@ export interface TaskExitedEvent { readonly signal?: string; } +export interface TaskOutputEvent { + readonly taskId: number; + readonly ctx?: string; + readonly line: string; +} + +export interface TaskOutputProcessedEvent { + readonly taskId: number; + readonly ctx?: string; + readonly problems?: ProblemMatch[]; +} + export interface TaskClient { onTaskExit(event: TaskExitedEvent): void; onTaskCreated(event: TaskInfo): void; + onDidProcessTaskOutput(event: TaskOutputProcessedEvent): void; +} + +export interface TaskDefinition { + id: string; // contributor id + taskType: string; + properties: { + required: string[]; + all: string[]; + } +} + +export interface TaskDefinitionContribution { + type: string; + required: string[]; + properties: { + [name: string]: { + type: string; + description?: string; + // tslint:disable-next-line:no-any + [additionalProperty: string]: any; + } + } +} + +export interface WatchingMatcherContribution { + // If set to true the background monitor is in active mode when the task starts. + // This is equals of issuing a line that matches the beginPattern + activeOnStart?: boolean; + beginsPattern: string | WatchingPattern; + endsPattern: string | WatchingPattern; +} + +export interface ProblemMatcherContribution { + name: string; + label: string; + deprecated?: boolean; + + owner: string; + source?: string; + applyTo: string; + fileLocation?: 'absolute' | 'relative' | string[]; + filePrefix?: string; + pattern?: string | ProblemPatternContribution | ProblemPatternContribution[]; + severity?: string; + watching?: WatchingMatcherContribution; // deprecated. Use `background`. + background?: WatchingMatcherContribution; +} + +export interface ProblemPatternContribution { + name?: string; + regexp: string; + + kind?: string; + file?: number; + message?: number; + location?: number; + line?: number; + character?: number; + endLine?: number; + endCharacter?: number; + code?: number; + severity?: number; + loop?: boolean; +} + +export const TaskDefinitionRegistry = Symbol('TaskDefinitionRegistry'); +export interface TaskDefinitionRegistry { + getDefinitions(taskType: string): TaskDefinition[]; + getDefinition(taskConfiguration: TaskConfiguration): TaskDefinition | undefined; + register(definition: TaskDefinitionContribution, pluginId: string): void; +} + +export const ProblemPatternRegistry = Symbol('ProblemPatternRegistry'); +export interface ProblemPatternRegistry { + onReady(): Promise; + register(key: string, value: ProblemPatternContribution | ProblemPatternContribution[]): void; + get(key: string): undefined | ProblemPattern | ProblemPattern[]; +} + +export const ProblemMatcherRegistry = Symbol('ProblemMatcherRegistry'); +export interface ProblemMatcherRegistry { + onReady(): Promise; + register(matcher: ProblemMatcherContribution): Promise; + get(name: string): NamedProblemMatcher | undefined; + getProblemMatcherFromContribution(matcher: ProblemMatcherContribution): Promise; } diff --git a/packages/task/src/common/task-watcher.ts b/packages/task/src/common/task-watcher.ts index d3f60b686d881..7e0399e770817 100644 --- a/packages/task/src/common/task-watcher.ts +++ b/packages/task/src/common/task-watcher.ts @@ -16,7 +16,7 @@ import { injectable } from 'inversify'; import { Emitter, Event } from '@theia/core/lib/common/event'; -import { TaskClient, TaskExitedEvent, TaskInfo } from './task-protocol'; +import { TaskClient, TaskExitedEvent, TaskInfo, TaskOutputProcessedEvent } from './task-protocol'; @injectable() export class TaskWatcher { @@ -24,18 +24,23 @@ export class TaskWatcher { getTaskClient(): TaskClient { const newTaskEmitter = this.onTaskCreatedEmitter; const exitEmitter = this.onTaskExitEmitter; + const outputProcessedEmitter = this.onOutputProcessedEmitter; return { onTaskCreated(event: TaskInfo) { newTaskEmitter.fire(event); }, onTaskExit(event: TaskExitedEvent) { exitEmitter.fire(event); + }, + onDidProcessTaskOutput(event: TaskOutputProcessedEvent) { + outputProcessedEmitter.fire(event); } }; } protected onTaskCreatedEmitter = new Emitter(); protected onTaskExitEmitter = new Emitter(); + protected onOutputProcessedEmitter = new Emitter(); get onTaskCreated(): Event { return this.onTaskCreatedEmitter.event; @@ -43,4 +48,7 @@ export class TaskWatcher { get onTaskExit(): Event { return this.onTaskExitEmitter.event; } + get onOutputProcessed(): Event { + return this.onOutputProcessedEmitter.event; + } } diff --git a/packages/task/src/node/process/process-task.ts b/packages/task/src/node/process/process-task.ts index df5c8c5afc315..3abd3fc7bcd2a 100644 --- a/packages/task/src/node/process/process-task.ts +++ b/packages/task/src/node/process/process-task.ts @@ -16,15 +16,32 @@ import { injectable, inject, named } from 'inversify'; import { ILogger } from '@theia/core/lib/common/'; -import { Process } from '@theia/process/lib/node'; +import { Process, IProcessExitEvent } from '@theia/process/lib/node'; import { Task, TaskOptions } from '../task'; import { TaskManager } from '../task-manager'; import { ProcessType, ProcessTaskInfo } from '../../common/process/task-protocol'; +import { TaskExitedEvent } from '../../common/task-protocol'; + +// Escape codes +// http://en.wikipedia.org/wiki/ANSI_escape_code +const EL = /\x1B\x5B[12]?K/g; // Erase in line +const COLOR_START = /\x1b\[\d+m/g; // Color +const COLOR_END = /\x1b\[0?m/g; // Color + +export function removeAnsiEscapeCodes(str: string): string { + if (str) { + str = str.replace(EL, ''); + str = str.replace(COLOR_START, ''); + str = str.replace(COLOR_END, ''); + } + + return str.trimRight(); +} export const TaskProcessOptions = Symbol('TaskProcessOptions'); export interface TaskProcessOptions extends TaskOptions { - process: Process, - processType: ProcessType + process: Process; + processType: ProcessType; } export const TaskFactory = Symbol('TaskFactory'); @@ -41,17 +58,34 @@ export class ProcessTask extends Task { ) { super(taskManager, logger, options); - const toDispose = - this.process.onExit(event => { - toDispose.dispose(); - this.fireTaskExited({ + const toDispose = this.process.onExit(async event => { + toDispose.dispose(); + this.fireTaskExited(await this.getTaskExitedEvent(event)); + }); + + // Buffer to accumulate incoming output. + let databuf: string = ''; + this.process.outputStream.on('data', (chunk: string) => { + databuf += chunk; + + while (1) { + // Check if we have a complete line. + const eolIdx = databuf.indexOf('\n'); + if (eolIdx < 0) { + break; + } + + // Get and remove the line from the data buffer. + const lineBuf = databuf.slice(0, eolIdx); + databuf = databuf.slice(eolIdx + 1); + const processedLine = removeAnsiEscapeCodes(lineBuf); + this.fireOutputLine({ taskId: this.taskId, - ctx: this.options.context, - code: event.code, - signal: event.signal + ctx: this.context, + line: processedLine }); - }); - + } + }); this.logger.info(`Created new task, id: ${this.id}, process id: ${this.options.process.id}, OS PID: ${this.process.pid}, context: ${this.context}`); } @@ -69,6 +103,15 @@ export class ProcessTask extends Task { }); } + protected async getTaskExitedEvent(evt: IProcessExitEvent): Promise { + return { + taskId: this.taskId, + ctx: this.options.context, + code: evt.code, + signal: evt.signal + }; + } + getRuntimeInfo(): ProcessTaskInfo { return { taskId: this.id, @@ -77,6 +120,7 @@ export class ProcessTask extends Task { terminalId: (this.processType === 'shell') ? this.process.id : undefined }; } + get process() { return this.options.process; } diff --git a/packages/task/src/node/task-abstract-line-matcher.ts b/packages/task/src/node/task-abstract-line-matcher.ts new file mode 100644 index 0000000000000..f22dc1ca76206 --- /dev/null +++ b/packages/task/src/node/task-abstract-line-matcher.ts @@ -0,0 +1,293 @@ +/******************************************************************************** + * Copyright (C) 2019 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isWindows } from '@theia/core/lib/common/os'; +import { Diagnostic, DiagnosticSeverity, Range } from 'vscode-languageserver-types'; +import { + FileLocationKind, ProblemMatcher, ProblemPattern, ProblemData, + ProblemMatch, ProblemMatchData, ProblemLocationKind, Severity +} from '../common/problem-matcher-protocol'; +import URI from '@theia/core/lib/common/uri'; +import vscodeURI from 'vscode-uri/lib/umd'; + +const endOfLine: string = isWindows ? '\r\n' : '\n'; + +export abstract class AbstractLineMatcher { + + protected patterns: ProblemPattern[] = []; + protected activePatternIndex: number = 0; + protected activePattern: ProblemPattern | undefined; + protected cachedProblemData: ProblemData; + + constructor( + protected matcher: ProblemMatcher + ) { + if (Array.isArray(matcher.pattern)) { + this.patterns = matcher.pattern; + } else { + this.patterns = [matcher.pattern]; + } + this.cachedProblemData = this.getEmptyProblemData(); + + if (this.patterns.slice(0, this.patternCount - 1).some(p => !!p.loop)) { + console.error('Problem Matcher: Only the last pattern can loop'); + } + } + + abstract match(line: string): ProblemMatch | undefined; + + get patternCount() { + return this.patterns.length; + } + + protected getEmptyProblemData(): ProblemData { + // tslint:disable-next-line:no-null-keyword + return Object.create(null) as ProblemData; + } + + protected fillProblemData(data: ProblemData | null, pattern: ProblemPattern, matches: RegExpExecArray): data is ProblemData { + if (data) { + this.fillProperty(data, 'file', pattern, matches, true); + this.appendProperty(data, 'message', pattern, matches, true); + this.fillProperty(data, 'code', pattern, matches, true); + this.fillProperty(data, 'severity', pattern, matches, true); + this.fillProperty(data, 'location', pattern, matches, true); + this.fillProperty(data, 'line', pattern, matches); + this.fillProperty(data, 'character', pattern, matches); + this.fillProperty(data, 'endLine', pattern, matches); + this.fillProperty(data, 'endCharacter', pattern, matches); + return true; + } + return false; + } + + private appendProperty(data: ProblemData, property: keyof ProblemData, pattern: ProblemPattern, matches: RegExpExecArray, trim: boolean = false): void { + const patternProperty = pattern[property]; + if (data[property] === undefined) { + this.fillProperty(data, property, pattern, matches, trim); + } else if (patternProperty !== undefined && patternProperty < matches.length) { + let value = matches[patternProperty]; + if (trim) { + value = value.trim(); + } + data[property] += endOfLine + value; + } + } + + private fillProperty(data: ProblemData, property: keyof ProblemData, pattern: ProblemPattern, matches: RegExpExecArray, trim: boolean = false): void { + const patternAtProperty = pattern[property]; + if (data[property] === undefined && patternAtProperty !== undefined && patternAtProperty < matches.length) { + let value = matches[patternAtProperty]; + if (value !== undefined) { + if (trim) { + value = value.trim(); + } + data[property] = value; + } + } + } + + protected getMarkerMatch(data: ProblemData): ProblemMatch | undefined { + try { + const location = this.getLocation(data); + if (data.file && location && data.message) { + const marker: Diagnostic = { + severity: this.getSeverity(data), + range: location, + message: data.message + }; + if (data.code !== undefined) { + marker.code = data.code; + } + if (this.matcher.source !== undefined) { + marker.source = this.matcher.source; + } + return { + description: this.matcher, + resource: this.getResource(data.file, this.matcher), + marker + } as ProblemMatchData; + } else { + return { + description: this.matcher + }; + } + } catch (err) { + console.error(`Failed to convert problem data into match: ${JSON.stringify(data)}`); + } + return undefined; + } + + private getLocation(data: ProblemData): Range | null { + if (data.kind === ProblemLocationKind.File) { + return this.createRange(0, 0, 0, 0); + } + if (data.location) { + return this.parseLocationInfo(data.location); + } + if (!data.line) { + // tslint:disable-next-line:no-null-keyword + return null; + } + const startLine = parseInt(data.line); + const startColumn = data.character ? parseInt(data.character) : undefined; + const endLine = data.endLine ? parseInt(data.endLine) : undefined; + const endColumn = data.endCharacter ? parseInt(data.endCharacter) : undefined; + return this.createRange(startLine, startColumn, endLine, endColumn); + } + + private parseLocationInfo(value: string): Range | null { + if (!value || !value.match(/(\d+|\d+,\d+|\d+,\d+,\d+,\d+)/)) { + // tslint:disable-next-line:no-null-keyword + return null; + } + const parts = value.split(','); + const startLine = parseInt(parts[0]); + const startColumn = parts.length > 1 ? parseInt(parts[1]) : undefined; + if (parts.length > 3) { + return this.createRange(startLine, startColumn, parseInt(parts[2]), parseInt(parts[3])); + } else { + return this.createRange(startLine, startColumn, undefined, undefined); + } + } + + private createRange(startLine: number, startColumn: number | undefined, endLine: number | undefined, endColumn: number | undefined): Range { + let range: Range; + if (startColumn !== undefined) { + if (endColumn !== undefined) { + range = Range.create(startLine, startColumn, endLine || startLine, endColumn); + } else { + range = Range.create(startLine, startColumn, startLine, startColumn); + } + } else { + range = Range.create(startLine, 1, startLine, Number.MAX_VALUE); + } + + // range indexes should be zero-based + return Range.create( + this.getZeroBasedRangeIndex(range.start.line), + this.getZeroBasedRangeIndex(range.start.character), + this.getZeroBasedRangeIndex(range.end.line), + this.getZeroBasedRangeIndex(range.end.character) + ); + } + + private getZeroBasedRangeIndex(ind: number): number { + return ind === 0 ? ind : ind - 1; + } + + private getSeverity(data: ProblemData): DiagnosticSeverity { + // tslint:disable-next-line:no-null-keyword + let result: Severity | null = null; + if (data.severity) { + const value = data.severity; + if (value) { + result = Severity.fromValue(value); + if (result === Severity.Ignore) { + if (value === 'E') { + result = Severity.Error; + } else if (value === 'W') { + result = Severity.Warning; + } else if (value === 'I') { + result = Severity.Info; + } else if (value.toLowerCase() === 'hint') { + result = Severity.Info; + } else if (value.toLowerCase() === 'note') { + result = Severity.Info; + } + } + } + } + if (result === null || result === Severity.Ignore) { + result = this.matcher.severity || Severity.Error; + } + return Severity.toDiagnosticSeverity(result); + } + + private getResource(filename: string, matcher: ProblemMatcher): vscodeURI { + const kind = matcher.fileLocation; + let fullPath: string | undefined; + if (kind === FileLocationKind.Absolute) { + fullPath = filename; + } else if ((kind === FileLocationKind.Relative) && matcher.filePrefix) { + let relativeFileName = filename.replace(/\\/g, '/'); + if (relativeFileName.startsWith('./')) { + relativeFileName = relativeFileName.slice(2); + } + fullPath = new URI(matcher.filePrefix).resolve(relativeFileName).toString(); + } + if (fullPath === undefined) { + throw new Error('FileLocationKind is not actionable. Does the matcher have a filePrefix? This should never happen.'); + } + fullPath = fullPath.replace(/\\/g, '/'); + if (fullPath[0] !== '/') { + fullPath = '/' + fullPath; + } + if (matcher.uriProvider !== undefined) { + return matcher.uriProvider(fullPath); + } else { + return vscodeURI.file(fullPath); + } + } + + protected resetActivePatternIndex(defaultIndex?: number): void { + if (defaultIndex === undefined) { + defaultIndex = 0; + } + this.activePatternIndex = defaultIndex; + this.activePattern = this.patterns[defaultIndex]; + } + + protected nextProblemPattern(): void { + this.activePatternIndex++; + if (this.activePatternIndex > this.patternCount - 1) { + this.resetActivePatternIndex(); + } else { + this.activePattern = this.patterns[this.activePatternIndex]; + } + } + + protected doOneLineMatch(line: string): boolean { + if (this.activePattern) { + const regexp = new RegExp(this.activePattern.regexp); + const regexMatches = regexp.exec(line); + if (regexMatches) { + if (this.activePattern.kind !== undefined && this.cachedProblemData.kind !== undefined) { + this.cachedProblemData.kind = this.activePattern.kind; + } + return this.fillProblemData(this.cachedProblemData, this.activePattern, regexMatches); + } + } + return false; + } + + // check if active pattern is the last pattern + protected isUsingTheLastPattern(): boolean { + return this.patternCount > 0 && this.activePatternIndex === this.patternCount - 1; + } + + protected isLastPatternLoop(): boolean { + return this.patternCount > 0 && !!this.patterns[this.patternCount - 1].loop; + } + + protected resetCachedProblemData(): void { + this.cachedProblemData = this.getEmptyProblemData(); + } +} diff --git a/packages/task/src/node/task-backend-module.ts b/packages/task/src/node/task-backend-module.ts index e01e8fe0ccbf8..192e58cd211b5 100644 --- a/packages/task/src/node/task-backend-module.ts +++ b/packages/task/src/node/task-backend-module.ts @@ -24,12 +24,13 @@ import { TaskManager } from './task-manager'; import { TaskRunnerContribution, TaskRunnerRegistry } from './task-runner'; import { TaskServerImpl } from './task-server'; import { createCommonBindings } from '../common/task-common-module'; -import { TaskClient, TaskServer, taskPath } from '../common/task-protocol'; +import { TaskClient, TaskServer, taskPath } from '../common'; export default new ContainerModule(bind => { bind(TaskManager).toSelf().inSingletonScope(); bind(BackendApplicationContribution).toService(TaskManager); + bind(TaskServer).to(TaskServerImpl).inSingletonScope(); bind(ConnectionHandler).toDynamicValue(ctx => new JsonRpcConnectionHandler(taskPath, client => { diff --git a/packages/task/src/node/task-line-matchers.ts b/packages/task/src/node/task-line-matchers.ts new file mode 100644 index 0000000000000..6ee413edda58d --- /dev/null +++ b/packages/task/src/node/task-line-matchers.ts @@ -0,0 +1,119 @@ +/******************************************************************************** + * Copyright (C) 2019 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { AbstractLineMatcher } from './task-abstract-line-matcher'; +import { ProblemMatcher, ProblemMatch, WatchingPattern } from '../common/problem-matcher-protocol'; + +export class StartStopLineMatcher extends AbstractLineMatcher { + + constructor( + protected matcher: ProblemMatcher + ) { + super(matcher); + } + + match(line: string): ProblemMatch | undefined { + if (!this.activePattern) { + this.resetActivePatternIndex(); + } + if (this.activePattern) { + const originalProblemData = Object.assign(this.getEmptyProblemData(), this.cachedProblemData); + const foundMatch = this.doOneLineMatch(line); + if (foundMatch) { + if (this.isUsingTheLastPattern()) { + const matchResult = this.getMarkerMatch(this.cachedProblemData); + if (this.isLastPatternLoop()) { + this.cachedProblemData = originalProblemData; + } else { + this.resetCachedProblemData(); + this.resetActivePatternIndex(); + } + return matchResult; + } else { + this.nextProblemPattern(); + } + } else { + this.resetCachedProblemData(); + if (this.activePatternIndex !== 0) { // if no match, use the first pattern to parse the same line + this.resetActivePatternIndex(); + return this.match(line); + } + } + } + return undefined; + } +} + +export class WatchModeLineMatcher extends StartStopLineMatcher { + + private beginsPattern: WatchingPattern; + private endsPattern: WatchingPattern; + private activeOnStart: boolean = false; + + constructor( + protected matcher: ProblemMatcher + ) { + super(matcher); + this.beginsPattern = matcher.watching!.beginsPattern; + this.endsPattern = matcher.watching!.endsPattern; + this.activeOnStart = matcher.watching!.activeOnStart === true; + this.resetActivePatternIndex(this.activeOnStart ? 0 : -1); + } + + match(line: string): ProblemMatch | undefined { + if (this.activeOnStart) { + this.activeOnStart = false; + this.resetActivePatternIndex(0); + this.resetCachedProblemData(); + return super.match(line); + } + + if (this.matchBegin(line)) { + const beginsPatternMatch = this.getMarkerMatch(this.cachedProblemData); + this.resetCachedProblemData(); + return beginsPatternMatch; + } + if (this.matchEnd(line)) { + this.resetCachedProblemData(); + return undefined; + } + if (this.activePattern) { + return super.match(line); + } + return undefined; + } + + private matchBegin(line: string): boolean { + const beginRegexp = new RegExp(this.beginsPattern.regexp); + const regexMatches = beginRegexp.exec(line); + if (regexMatches) { + this.fillProblemData(this.cachedProblemData, this.beginsPattern, regexMatches); + this.resetActivePatternIndex(0); + return true; + } + return false; + } + + private matchEnd(line: string): boolean { + const endRegexp = new RegExp(this.endsPattern.regexp); + const match = endRegexp.exec(line); + if (match) { + this.resetActivePatternIndex(-1); + return true; + } + return false; + } +} diff --git a/packages/task/src/node/task-problem-collector.spec.ts b/packages/task/src/node/task-problem-collector.spec.ts new file mode 100644 index 0000000000000..dd75d2f7c2f20 --- /dev/null +++ b/packages/task/src/node/task-problem-collector.spec.ts @@ -0,0 +1,259 @@ +/******************************************************************************** + * Copyright (C) 2019 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { expect } from 'chai'; +import { DiagnosticSeverity } from 'vscode-languageserver-types'; +import { ProblemCollector } from './task-problem-collector'; +import { ApplyToKind, FileLocationKind, ProblemLocationKind, ProblemMatch, ProblemMatchData, ProblemMatcher, Severity } from '../common/problem-matcher-protocol'; + +const startStopMatcher1: ProblemMatcher = { + owner: 'test1', + source: 'test1', + applyTo: ApplyToKind.allDocuments, + fileLocation: FileLocationKind.Absolute, + pattern: { + regexp: /^([^:]*: )?((.:)?[^:]*):(\d+)(:(\d+))?: (.*)$/.source, + kind: ProblemLocationKind.Location, + file: 2, + line: 4, + character: 6, + message: 7 + }, + severity: Severity.Error +}; + +const startStopMatcher2: ProblemMatcher = { + owner: 'test2', + source: 'test2', + applyTo: ApplyToKind.allDocuments, + fileLocation: FileLocationKind.Absolute, + pattern: [ + { + regexp: /^([^\s].*)$/.source, + kind: ProblemLocationKind.Location, + file: 1 + }, + { + regexp: /^\s+(\d+):(\d+)\s+(error|warning|info)\s+(.+?)(?:\s\s+(.*))?$/.source, + line: 1, + character: 2, + severity: 3, + message: 4, + code: 5, + loop: true + } + ], + severity: Severity.Error +}; + +const watchMatcher: ProblemMatcher = { + owner: 'test3', + applyTo: ApplyToKind.closedDocuments, + fileLocation: FileLocationKind.Absolute, + pattern: { + regexp: /Error: ([^(]+)\((\d+|\d+,\d+|\d+,\d+,\d+,\d+)\): (.*)$/.source, + file: 1, + location: 2, + message: 3 + }, + watching: { + activeOnStart: false, + beginsPattern: { regexp: /Starting compilation/.source }, + endsPattern: { regexp: /Finished compilation/.source } + } +}; + +describe('ProblemCollector', () => { + let collector: ProblemCollector; + const allMatches: ProblemMatch[] = []; + + const collectMatches = (lines: string[]) => { + lines.forEach(line => { + const matches = collector.processLine(line); + if (matches.length > 0) { + allMatches.push(...matches); + } + }); + }; + + beforeEach(() => { + allMatches.length = 0; + }); + + it('should find problems from start-stop task when problem matcher is associated with one problem pattern', () => { + collector = new ProblemCollector([startStopMatcher1]); + collectMatches([ + 'npm WARN lifecycle The node binary used for scripts is /tmp/yarn--1557403301319-0.5645247996849125/node but npm is using /usr/local/bin/node itself.', + 'Use the `--scripts-prepend-node-path` option to include the path for the node binary npm was executed with.', + '', + '# command-line-arguments', + '/home/test/hello.go:9:2: undefined: fmt.Pntln', + '/home/test/hello.go:10:6: undefined: numb', + '/home/test/hello.go:15:9: undefined: stri' + ]); + + expect(allMatches.length).to.eq(3); + + expect((allMatches[0] as ProblemMatchData).resource!.path).eq('/home/test/hello.go'); + expect((allMatches[0] as ProblemMatchData).marker).deep.equal({ + range: { start: { line: 8, character: 1 }, end: { line: 8, character: 1 } }, + severity: DiagnosticSeverity.Error, + source: 'test1', + message: 'undefined: fmt.Pntln' + }); + + expect((allMatches[1] as ProblemMatchData).resource!.path).eq('/home/test/hello.go'); + expect((allMatches[1] as ProblemMatchData).marker).deep.equal({ + range: { start: { line: 9, character: 5 }, end: { line: 9, character: 5 } }, + severity: DiagnosticSeverity.Error, + source: 'test1', + message: 'undefined: numb' + }); + + expect((allMatches[2] as ProblemMatchData).resource!.path).eq('/home/test/hello.go'); + expect((allMatches[2] as ProblemMatchData).marker).deep.equal({ + range: { start: { line: 14, character: 8 }, end: { line: 14, character: 8 } }, + severity: DiagnosticSeverity.Error, + source: 'test1', + message: 'undefined: stri' + }); + }); + + it('should find problems from start-stop task when problem matcher is associated with more than one problem pattern', () => { + collector = new ProblemCollector([startStopMatcher2]); + collectMatches([ + '> test@0.1.0 lint /home/test', + '> eslint .', + '', + '', + '/home/test/test-dir.js', + ' 14:21 warning Missing semicolon semi', + ' 15:23 warning Missing semicolon semi', + ' 103:9 error Parsing error: Unexpected token inte', + '', + '/home/test/more-test.js', + ' 13:9 error Parsing error: Unexpected token 1000', + '', + '✖ 3 problems (1 error, 2 warnings)', + ' 0 errors and 2 warnings potentially fixable with the `--fix` option.' + ]); + + expect(allMatches.length).to.eq(4); + + expect((allMatches[0] as ProblemMatchData).resource!.path).eq('/home/test/test-dir.js'); + expect((allMatches[0] as ProblemMatchData).marker).deep.equal({ + range: { start: { line: 13, character: 20 }, end: { line: 13, character: 20 } }, + severity: DiagnosticSeverity.Warning, + source: 'test2', + message: 'Missing semicolon', + code: 'semi' + }); + + expect((allMatches[1] as ProblemMatchData).resource!.path).eq('/home/test/test-dir.js'); + expect((allMatches[1] as ProblemMatchData).marker).deep.equal({ + range: { start: { line: 14, character: 22 }, end: { line: 14, character: 22 } }, + severity: DiagnosticSeverity.Warning, + source: 'test2', + message: 'Missing semicolon', + code: 'semi' + }); + + expect((allMatches[2] as ProblemMatchData).resource!.path).eq('/home/test/test-dir.js'); + expect((allMatches[2] as ProblemMatchData).marker).deep.equal({ + range: { start: { line: 102, character: 8 }, end: { line: 102, character: 8 } }, + severity: DiagnosticSeverity.Error, + source: 'test2', + message: 'Parsing error: Unexpected token inte' + }); + + expect((allMatches[3] as ProblemMatchData).resource!.path).eq('/home/test/more-test.js'); + expect((allMatches[3] as ProblemMatchData).marker).deep.equal({ + range: { start: { line: 12, character: 8 }, end: { line: 12, character: 8 } }, + severity: DiagnosticSeverity.Error, + source: 'test2', + message: 'Parsing error: Unexpected token 1000' + }); + }); + + it('should search and find defined problems from watch task\'s output', () => { + collector = new ProblemCollector([watchMatcher]); + + collectMatches([ + '> code-oss-dev@1.33.1 watch /home/test/vscode', + '> gulp watch --max_old_space_size=4095', + '', + '[09:15:37] Node flags detected: --max_old_space_size=4095', + '[09:15:37] Respawned to PID: 14560', + '[09:15:40] Using gulpfile ~/dev/vscode/gulpfile.js', + "[09:15:40] Starting 'watch'...", + '[09:15:40] Starting clean-out ...', + '[09:15:41] Starting clean-extension-configuration-editing ...', + '[09:15:41] Starting clean-extension-css-language-features-client ...', + '[09:15:41] Starting clean-extension-css-language-features-server ...', + '[09:15:41] Starting clean-extension-debug-auto-launch ...', + '[09:15:41] Starting watch-extension:markdown-language-features-preview-src ...', + '[09:15:41] Starting compilation...', // begin pattern 1 + '[09:15:41] Finished clean-extension-typescript-basics-test-colorize-fixtures after 49 ms', + '[09:15:41] Starting watch-extension:typescript-basics-test-colorize-fixtures ...', + '[09:15:41] Finished compilation with 0 errors after 30 ms', + '[09:15:41] Finished clean-extension-css-language-features-client after 119 ms', + '[09:15:41] Starting watch-extension:css-language-features-client ...', + '[09:15:41] Starting compilation...', // begin pattern 2 + '[09:15:41] Finished clean-extension-configuration-editing after 128 ms', + '[09:15:41] Starting watch-extension:configuration-editing ...', + '[09:15:41] Finished clean-extension-debug-auto-launch after 133 ms', + '[09:15:41] Starting watch-extension:debug-auto-launch ...', + '[09:15:41] Finished clean-extension-debug-server-ready after 138 ms', + '[09:15:52] Starting watch-extension:html-language-features-server ...', + '[09:15:58] Finished clean-out after 17196 ms', + '[09:15:58] Starting watch-client ...', + '[09:17:25] Finished compilation with 0 errors after 104209 ms', + '[09:19:22] Starting compilation...', // begin pattern 3 + "[09:19:23] Error: /home/test/src/vs/workbench/contrib/tasks/common/taskConfiguration.ts(517,19): ')' expected.", // problem 1 + "[09:19:23] Error: /home/test/src/vs/workbench/contrib/tasks/common/taskConfiguration.ts(517,57): ';' expected.", // problem 2 + '[09:19:23] Finished compilation with 2 errors after 1051 ms', + '[09:20:21] Starting compilation...', // begin pattern 4 + "[09:20:24] Error: /home/test/src/vs/workbench/contrib/tasks/common/problemCollectors.ts(15,30): Cannot find module 'n/uuid'.", // problem 3 + "[09:20:24] Error: /home/test/src/vs/workbench/contrib/tasks/common/taskConfiguration.ts(517,19): ')' expected.", // problem 4 + "[09:20:24] Error: /home/test/src/vs/workbench/contrib/tasks/common/taskConfiguration.ts(517,57): ';' expected.", // problem 5 + '[09:20:24] Finished compilation with 3 errors after 2586 ms', + '[09:20:24] Starting compilation...', // begin pattern 5 + '[09:20:25] Error: /home/test/src/vs/workbench/contrib/tasks/common/taskTemplates.ts(12,14): Type expected.', // problem 6 + "[09:20:25] Error: /home/test/src/vs/workbench/contrib/tasks/common/problemCollectors.ts(15,30): Cannot find module 'n/uuid'.", // problem 7 + "[09:20:25] Error: /home/test/src/vs/workbench/contrib/tasks/common/taskConfiguration.ts(517,19): ')' expected.", // problem 8 + "[09:20:25] Error: /home/test/src/vs/workbench/contrib/tasks/common/taskConfiguration.ts(517,57): ';' expected.", // problem 9 + '[09:20:25] Finished compilation with 4 errors after 441 ms' + ]); + + expect(allMatches.length).to.eq(14); // 9 events for problems + 5 events for beginner pattern + }); + + it('should return an empty array if no problems are found', () => { + collector = new ProblemCollector([startStopMatcher2]); + + collectMatches([]); + expect(allMatches.length).to.eq(0); + + collectMatches([ + '> test@0.1.0 lint /home/test', + '> eslint .', + '', + '', + '0 problems (0 error, 0 warnings)', + ]); + expect(allMatches.length).to.eq(0); + }); +}); diff --git a/packages/task/src/node/task-problem-collector.ts b/packages/task/src/node/task-problem-collector.ts new file mode 100644 index 0000000000000..de3a5401a5eb5 --- /dev/null +++ b/packages/task/src/node/task-problem-collector.ts @@ -0,0 +1,47 @@ +/******************************************************************************** + * Copyright (C) 2019 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { AbstractLineMatcher } from './task-abstract-line-matcher'; +import { ProblemMatcher, ProblemMatch } from '../common/problem-matcher-protocol'; +import { StartStopLineMatcher, WatchModeLineMatcher } from './task-line-matchers'; + +export class ProblemCollector { + + private lineMatchers: AbstractLineMatcher[] = []; + + constructor( + protected problemMatchers: ProblemMatcher[] + ) { + for (const matcher of problemMatchers) { + if (ProblemMatcher.isWatchModeWatcher(matcher)) { + this.lineMatchers.push(new WatchModeLineMatcher(matcher)); + } else { + this.lineMatchers.push(new StartStopLineMatcher(matcher)); + } + } + } + + processLine(line: string): ProblemMatch[] { + const markers: ProblemMatch[] = []; + this.lineMatchers.forEach(lineMatcher => { + const match = lineMatcher.match(line); + if (match) { + markers.push(match); + } + }); + return markers; + } +} diff --git a/packages/task/src/node/task-server.ts b/packages/task/src/node/task-server.ts index 6f615b21c8302..d58186d3d3839 100644 --- a/packages/task/src/node/task-server.ts +++ b/packages/task/src/node/task-server.ts @@ -16,9 +16,18 @@ import { inject, injectable, named } from 'inversify'; import { ILogger } from '@theia/core/lib/common/'; -import { TaskClient, TaskExitedEvent, TaskInfo, TaskServer, TaskConfiguration } from '../common/task-protocol'; +import { + TaskClient, + TaskExitedEvent, + TaskInfo, + TaskServer, + TaskConfiguration, + TaskOutputProcessedEvent, + RunTaskOption, +} from '../common'; import { TaskManager } from './task-manager'; import { TaskRunnerRegistry } from './task-runner'; +import { ProblemCollector } from './task-problem-collector'; @injectable() export class TaskServerImpl implements TaskServer { @@ -35,6 +44,9 @@ export class TaskServerImpl implements TaskServer { @inject(TaskRunnerRegistry) protected readonly runnerRegistry: TaskRunnerRegistry; + /** task context - {task id - problem collector} */ + private problemCollectors: Map> = new Map(); + dispose() { // do nothing } @@ -53,7 +65,7 @@ export class TaskServerImpl implements TaskServer { return Promise.resolve(taskInfo); } - async run(taskConfiguration: TaskConfiguration, ctx?: string): Promise { + async run(taskConfiguration: TaskConfiguration, ctx?: string, option?: RunTaskOption): Promise { const runner = this.runnerRegistry.getRunner(taskConfiguration.type); const task = await runner.run(taskConfiguration, ctx); @@ -62,6 +74,30 @@ export class TaskServerImpl implements TaskServer { this.fireTaskExitedEvent(event); }); + const resolvedMatchers = option && option.customization ? option.customization.problemMatcher || [] : []; + if (resolvedMatchers.length > 0) { + task.onOutput(event => { + let collector: ProblemCollector | undefined = this.getCachedProblemCollector(event.ctx || '', event.taskId); + if (!collector) { + collector = new ProblemCollector(resolvedMatchers); + this.cacheProblemCollector(event.ctx || '', event.taskId, collector); + } + + const problems = collector.processLine(event.line); + if (problems.length > 0) { + this.fireTaskOutputProcessedEvent({ + taskId: event.taskId, + ctx: event.ctx, + problems + }); + } + }); + } + + task.onExit(event => { + this.removedCachedProblemCollector(event.ctx || '', event.taskId); + }); + const taskInfo = await task.getRuntimeInfo(); this.fireTaskCreatedEvent(taskInfo); return taskInfo; @@ -87,6 +123,10 @@ export class TaskServerImpl implements TaskServer { }); } + protected fireTaskOutputProcessedEvent(event: TaskOutputProcessedEvent) { + this.clients.forEach(client => client.onDidProcessTaskOutput(event)); + } + /** Kill task for a given id. Rejects if task is not found */ async kill(id: number): Promise { const taskToKill = this.taskManager.get(id); @@ -113,4 +153,28 @@ export class TaskServerImpl implements TaskServer { this.clients.splice(idx, 1); } } + + private getCachedProblemCollector(ctx: string, taskId: number): ProblemCollector | undefined { + if (this.problemCollectors.has(ctx)) { + return this.problemCollectors.get(ctx)!.get(taskId); + } + } + + private cacheProblemCollector(ctx: string, taskId: number, problemCollector: ProblemCollector): void { + if (this.problemCollectors.has(ctx)) { + if (!this.problemCollectors.get(ctx)!.has(taskId)) { + this.problemCollectors.get(ctx)!.set(taskId, problemCollector); + } + } else { + const forNewContext = new Map(); + forNewContext.set(taskId, problemCollector); + this.problemCollectors.set(ctx, forNewContext); + } + } + + private removedCachedProblemCollector(ctx: string, taskId: number): void { + if (this.problemCollectors.has(ctx) && this.problemCollectors.get(ctx)!.has(taskId)) { + this.problemCollectors.get(ctx)!.delete(taskId); + } + } } diff --git a/packages/task/src/node/task.ts b/packages/task/src/node/task.ts index f8d621a3dbc6c..3cbca191820b0 100644 --- a/packages/task/src/node/task.ts +++ b/packages/task/src/node/task.ts @@ -17,12 +17,12 @@ import { injectable } from 'inversify'; import { ILogger, Emitter, Event, MaybePromise } from '@theia/core/lib/common/'; import { TaskManager } from './task-manager'; -import { TaskInfo, TaskExitedEvent, TaskConfiguration } from '../common/task-protocol'; +import { TaskInfo, TaskExitedEvent, TaskConfiguration, TaskOutputEvent, TaskOutputProcessedEvent } from '../common/task-protocol'; export interface TaskOptions { - label: string, - config: TaskConfiguration - context?: string + label: string; + config: TaskConfiguration; + context?: string; } @injectable() @@ -30,6 +30,8 @@ export abstract class Task { protected taskId: number; readonly exitEmitter: Emitter; + readonly outputEmitter: Emitter; + readonly outputProcessedEmitter: Emitter; constructor( protected readonly taskManager: TaskManager, @@ -38,6 +40,7 @@ export abstract class Task { ) { this.taskId = this.taskManager.register(this, this.options.context); this.exitEmitter = new Emitter(); + this.outputEmitter = new Emitter(); } /** Terminates the task. */ @@ -47,11 +50,19 @@ export abstract class Task { return this.exitEmitter.event; } + get onOutput(): Event { + return this.outputEmitter.event; + } + /** Has to be called when a task has concluded its execution. */ protected fireTaskExited(event: TaskExitedEvent): void { this.exitEmitter.fire(event); } + protected fireOutputLine(event: TaskOutputEvent): void { + this.outputEmitter.fire(event); + } + /** Returns runtime information about task. */ abstract getRuntimeInfo(): MaybePromise;