diff --git a/packages/markers/src/browser/marker-manager.ts b/packages/markers/src/browser/marker-manager.ts index e83f800af9140..849ab9c0a8fbd 100644 --- a/packages/markers/src/browser/marker-manager.ts +++ b/packages/markers/src/browser/marker-manager.ts @@ -55,11 +55,20 @@ export class MarkerCollection { if (markerData.length > 0) { this.owner2Markers.set(owner, markerData.map(data => this.createMarker(owner, data))); } else { - this.owner2Markers.delete(owner); + this.removeMarkers(owner); } return before || []; } + addMarkers(owner: string, markerData: T[]): Marker[] { + if (markerData.length > 0) { + const existing = this.owner2Markers.get(owner) || []; + const toAdd = markerData.map(data => this.createMarker(owner, data)); + this.owner2Markers.set(owner, [...existing, ...toAdd]); + } + return this.owner2Markers.get(owner) || []; + } + protected createMarker(owner: string, data: T): Readonly> { return Object.freeze({ uri: this.uri.toString(), @@ -95,6 +104,9 @@ export class MarkerCollection { } } + removeMarkers(owner: string): void { + this.owner2Markers.delete(owner); + } } export interface Uri2MarkerEntry { @@ -160,6 +172,22 @@ export abstract class MarkerManager { return oldMarkers; } + /* + * adds markers for the given uri and owner with the given data, without touching the exsting markers associated with the same uri and owner. + */ + addMarkers(uri: URI, owner: string, data: D[]): Marker[] { + const uriString = uri.toString(); + const collection = this.uri2MarkerCollection.get(uriString) || new MarkerCollection(uri, this.getKind()); + const newMarkers = collection.addMarkers(owner, data); + if (collection.empty) { + this.uri2MarkerCollection.delete(uri.toString()); + } else { + this.uri2MarkerCollection.set(uriString, collection); + } + this.fireOnDidChangeMarkers(uri); + return newMarkers; + } + /* * returns all markers that satisfy the given filter. */ @@ -197,4 +225,10 @@ export abstract class MarkerManager { } } + cleanMarkersByOwner(owner: string): void { + this.uri2MarkerCollection.forEach((collection, uri) => { + collection.removeMarkers(owner); + this.fireOnDidChangeMarkers(new URI(uri)); + }); + } } diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index 0529608372f4a..0be9a791f88b7 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'; @@ -66,6 +67,9 @@ export interface PluginPackageContribution { keybindings?: PluginPackageKeybinding[]; debuggers?: PluginPackageDebuggersContribution[]; snippets: PluginPackageSnippetsContribution[]; + taskDefinitions?: TaskDefinitionContribution[]; + problemMatchers?: ProblemMatcherContribution[]; + problemPatterns?: ProblemPatternContribution[]; } export interface PluginPackageViewContainer { @@ -359,6 +363,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 948ea701877d2..12befae8e5a58 100644 --- a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts +++ b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts @@ -56,6 +56,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) { @@ -184,6 +185,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 4543ea48bc047..f0bfcd0770062 100644 --- a/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts @@ -27,6 +27,7 @@ import { MonacoSnippetSuggestProvider } from '@theia/monaco/lib/browser/monaco-s import { PluginSharedStyle } from './plugin-shared-style'; import { CommandRegistry } from '@theia/core'; import { BuiltinThemeProvider } from '@theia/core/lib/browser/theming'; +import { TaskDefinitionRegistry, ProblemMatcherRegistry, ProblemPatternRegistry } from '@theia/task/lib/common'; @injectable() export class PluginContributionHandler { @@ -60,7 +61,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); } @@ -150,6 +160,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 pluginCommandIconId = 0; 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/process.ts b/packages/process/src/node/process.ts index 72aef85508963..72340c35e9c0a 100644 --- a/packages/process/src/node/process.ts +++ b/packages/process/src/node/process.ts @@ -108,6 +108,9 @@ export abstract class Process { return this.errorEmitter.event; } + abstract onData(listener: (buffer: string) => void): void; + abstract onDataClosed(listener: (exitCode: number, signal?: number) => void): void; + protected emitOnStarted() { this.startEmitter.fire({}); } diff --git a/packages/process/src/node/raw-process.ts b/packages/process/src/node/raw-process.ts index 6aba197d16d3e..c83edaac9d4b1 100644 --- a/packages/process/src/node/raw-process.ts +++ b/packages/process/src/node/raw-process.ts @@ -141,6 +141,14 @@ export class RawProcess extends Process { } } + onData(listener: (buffer: string) => void): void { + this.output.on('data', listener); + } + + onDataClosed(listener: (exitCode: number, signal?: number) => void): void { + this.output.on('close', listener); + } + get pid() { return this.process.pid; } diff --git a/packages/process/src/node/terminal-process.ts b/packages/process/src/node/terminal-process.ts index 0c71897137223..f9f968cbce07b 100644 --- a/packages/process/src/node/terminal-process.ts +++ b/packages/process/src/node/terminal-process.ts @@ -123,4 +123,11 @@ export class TerminalProcess extends Process { this.terminal.write(data); } + onData(listener: (buffer: string) => void): void { + this.terminal.on('data', listener); + } + + onDataClosed(listener: (exitCode: number, signal?: number) => void): void { + this.terminal.on('exit', listener); + } } 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/task-configurations.ts b/packages/task/src/browser/task-configurations.ts index 43e097cda2627..7d86144b92546 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 } 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,48 +207,46 @@ 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 configuredTasksArray = await this.readTasks(configFileUri); + if (configuredTasksArray) { // 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); } } + + const cutomizations = await this.readTaskCustomizations(configFileUri); + if (cutomizations) { + this.taskCustomizations.length = 0; + this.taskCustomizations = cutomizations; + } } /** parses a config file and extracts the tasks launch configurations */ protected async readTasks(uri: string): Promise { - if (!await this.fileSystem.exists(uri)) { - return undefined; - } else { - try { - const response = await this.fileSystem.resolveContent(uri); - - const strippedContent = jsoncparser.stripComments(response.content); - const errors: ParseError[] = []; - const tasks = jsoncparser.parse(strippedContent, errors); - - if (errors.length) { - for (const e of errors) { - console.error(`Error parsing ${uri}: error: ${e.error}, length: ${e.length}, offset: ${e.offset}`); - } - } else { - return this.filterDuplicates(tasks['tasks']).map(t => Object.assign(t, { _source: t.source || this.getSourceFolderFromConfigUri(uri) })); + const taskConfigs = await this.getTaskConfigurationsFromFile(uri); + if (taskConfigs) { + const filtered = this.filterDuplicates(taskConfigs); + const isContributed = await Promise.all(filtered.map(t => this.isContributedTask(t))); + const configuredTasks: TaskConfiguration[] = []; + filtered.forEach((t, index) => { + if (!isContributed[index]) { + configuredTasks.push(t); } - } catch (err) { - console.error(`Error(s) reading config file: ${uri}`); - } + }); + return configuredTasks.map(t => Object.assign(t, { _source: t.source || this.getSourceFolderFromConfigUri(uri) })); } + return undefined; } /** Adds given task to a config file and opens the file to provide ability to edit task configuration. */ @@ -301,4 +308,50 @@ export class TaskConfigurations implements Disposable { private getSourceFolderFromConfigUri(configFileUri: string): string { return new URI(configFileUri).parent.parent.path.toString(); } + + protected async readTaskCustomizations(uri: string): Promise { + const taskConfigs = await this.getTaskConfigurationsFromFile(uri); + if (taskConfigs) { + const isContributed = await Promise.all(taskConfigs.map((t: TaskConfiguration) => this.isContributedTask(t))); + const customizations: TaskCustomization[] = []; + taskConfigs.forEach((t: TaskConfiguration, index: number) => { + if (isContributed[index]) { + customizations.push(t); + } + }); + return customizations; + } + return undefined; + } + + private async isContributedTask(task: TaskConfiguration): Promise { + const taskDefinition = await this.taskDefinitionRegistry.getDefinition(task); + // it is considered as a customization if the task definition registry finds a def for the task configuration + return !!taskDefinition; + } + + private async getTaskConfigurationsFromFile(uri: string): Promise { + if (!await this.fileSystem.exists(uri)) { + return undefined; + } else { + try { + const response = await this.fileSystem.resolveContent(uri); + + const strippedContent = jsoncparser.stripComments(response.content); + const errors: ParseError[] = []; + const tasks = jsoncparser.parse(strippedContent, errors)['tasks']; + + if (errors.length) { + for (const e of errors) { + console.error(`Error parsing ${uri}: error: ${e.error}, length: ${e.length}, offset: ${e.offset}`); + } + return []; + } else { + return tasks as TaskConfiguration[]; + } + } catch (err) { + console.error(`Error(s) reading config file: ${uri}`); + } + } + } } diff --git a/packages/task/src/browser/task-frontend-module.ts b/packages/task/src/browser/task-frontend-module.ts index 8e95eba0efddc..5fc8743390d45 100644 --- a/packages/task/src/browser/task-frontend-module.ts +++ b/packages/task/src/browser/task-frontend-module.ts @@ -25,7 +25,12 @@ 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 { + TaskServer, taskPath, + problemMatcherPath, ProblemMatcherRegistry, + problemPatternPath, ProblemPatternRegistry, + taskDefinitionPath, TaskDefinitionRegistry +} from '../common/task-protocol'; import { TaskWatcher } from '../common/task-watcher'; import { bindProcessTaskModule } from './process/process-task-frontend-module'; import { TaskSchemaUpdater } from './task-schema-updater'; @@ -51,6 +56,15 @@ export default new ContainerModule(bind => { const taskWatcher = ctx.container.get(TaskWatcher); return connection.createProxy(taskPath, taskWatcher.getTaskClient()); }).inSingletonScope(); + bind(TaskDefinitionRegistry).toDynamicValue(({ container }) => + WebSocketConnectionProvider.createProxy(container, taskDefinitionPath) + ).inSingletonScope(); + bind(ProblemMatcherRegistry).toDynamicValue(({ container }) => + WebSocketConnectionProvider.createProxy(container, problemMatcherPath) + ).inSingletonScope(); + bind(ProblemPatternRegistry).toDynamicValue(({ container }) => + WebSocketConnectionProvider.createProxy(container, problemPatternPath) + ).inSingletonScope(); createCommonBindings(bind); diff --git a/packages/task/src/browser/task-service.ts b/packages/task/src/browser/task-service.ts index 0b9dfc4198af4..451a2b0e4cf22 100644 --- a/packages/task/src/browser/task-service.ts +++ b/packages/task/src/browser/task-service.ts @@ -23,9 +23,20 @@ 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 { + ProblemMatchData, + ProblemMatcherContribution, + ProblemMatcherRegistry, + TaskServer, + TaskExitedEvent, + TaskInfo, + TaskConfiguration, + TaskOutputProcessedEvent, + RunTaskOption +} from '../common'; import { TaskWatcher } from '../common/task-watcher'; import { TaskConfigurationClient, TaskConfigurations } from './task-configurations'; import { ProvidedTaskConfigurations } from './provided-task-configurations'; @@ -88,6 +99,12 @@ export class TaskService implements TaskConfigurationClient { @inject(EditorManager) protected readonly editorManager: EditorManager; + @inject(ProblemManager) + protected readonly problemManager: ProblemManager; + + @inject(ProblemMatcherRegistry) + protected readonly problemMatcherRegistry: ProblemMatcherRegistry; + /** * @deprecated To be removed in 0.5.0 */ @@ -115,6 +132,22 @@ export class TaskService implements TaskConfigurationClient { } }); + this.taskWatcher.onOutputProcessed((event: TaskOutputProcessedEvent) => { + if (!this.isEventForThisClient(event.ctx)) { + return; + } + if (event.problems) { + event.problems.forEach(problem => { + if (ProblemMatchData.is(problem) && problem.resource) { + const uri = new URI(problem.resource.path).withScheme(problem.resource.scheme); + this.problemManager.addMarkers(uri, problem.description.owner || 'external', [problem.marker]); + } else { // should have received an event for finding the "background task begins" pattern + this.problemManager.cleanMarkersByOwner(problem.description.owner); + } + }); + } + }); + // notify user that task has finished this.taskWatcher.onTaskExit((event: TaskExitedEvent) => { if (!this.isEventForThisClient(event.ctx)) { @@ -237,19 +270,24 @@ export class TaskService implements TaskConfigurationClient { * It looks for configured and provided tasks. */ async run(source: string, taskLabel: string): Promise { + const option: RunTaskOption = {}; let task = await this.getProvidedTask(source, taskLabel); - if (!task) { + 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 a provided task is found, check if it is customized + const taskType = task.taskType || task.type; + const customizations = this.taskConfigurations.getTaskCustomizations(taskType); + option.customizations = customizations; } - this.runTask(task); + this.runTask(task, option); } - async runTask(task: TaskConfiguration): Promise { + async runTask(task: TaskConfiguration, option?: RunTaskOption): Promise { const source = task._source; const taskLabel = task.label; @@ -264,9 +302,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 +323,33 @@ export class TaskService implements TaskConfigurationClient { } } + private async removeProblemMarks(option?: RunTaskOption): Promise { + if (option && option.customizations) { + const matchersFromOption = option.customizations.map(c => c.problemMatcher); + const flattenedMatchers: (string | ProblemMatcherContribution)[] = []; + for (const matcher of matchersFromOption) { + if (matcher) { + if (Array.isArray(matcher)) { + flattenedMatchers.push(...matcher); + } else { + flattenedMatchers.push(matcher); + } + } + } + const problemMatchers = await Promise.all(flattenedMatchers.map(matcher => { + if (typeof matcher === 'string') { + return this.problemMatcherRegistry.get(matcher); + } + return matcher; + })); + for (const matcher of problemMatchers) { + if (matcher && matcher.owner) { + this.problemManager.cleanMarkersByOwner(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..19d3175313e7f --- /dev/null +++ b/packages/task/src/common/problem-matcher-protocol.ts @@ -0,0 +1,260 @@ +/******************************************************************************** + * 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, WatchingPatternContribution } 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: RegExp; + file?: number; +} +export namespace WatchingPattern { + export function fromWatchingPatternContribution(value: WatchingPatternContribution): WatchingPattern { + return { + regexp: new RegExp(value.regexp), + file: value.file + }; + } +} + +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' + ? WatchingPattern.fromWatchingPatternContribution({ regexp: value.beginsPattern }) + : WatchingPattern.fromWatchingPatternContribution(value.beginsPattern), + endsPattern: typeof value.endsPattern === 'string' + ? WatchingPattern.fromWatchingPatternContribution({ regexp: value.endsPattern }) + : WatchingPattern.fromWatchingPatternContribution(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: RegExp; + + 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: new 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..0646a002b3ce9 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 } 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 { @@ -53,6 +57,12 @@ export namespace ContributedTaskConfiguration { return !!config && '_source' in config && '_scope' in config; } } +export namespace TaskConfiguration { + // tslint:disable-next-line:no-any + export function is(config: any): config is TaskConfiguration { + return !!config && typeof config === 'object' && 'label' in config && 'type' in config; + } +} /** Runtime information about Task. */ export interface TaskInfo { @@ -71,7 +81,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 +99,10 @@ export interface TaskServer extends JsonRpcServer { } +export interface RunTaskOption { + customizations?: TaskCustomization[]; +} + /** Event sent when a task has concluded its execution */ export interface TaskExitedEvent { readonly taskId: number; @@ -99,7 +113,113 @@ 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; + onTaskOutputProcessed(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 WatchingPatternContribution { + regexp: string; + file?: number; +} + +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 | WatchingPatternContribution; + endsPattern: string | WatchingPatternContribution; +} + +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 taskDefinitionPath = '/services/taskDefinitionRegistry'; +export const TaskDefinitionRegistry = Symbol('TaskDefinitionRegistry'); +export interface TaskDefinitionRegistry { + getDefinitions(taskType: string): TaskDefinition[]; + getDefinition(taskConfiguration: TaskConfiguration): TaskDefinition | undefined; + register(definition: TaskDefinitionContribution, pluginId: string): Promise; +} + +export const problemPatternPath = '/services/problemPatternRegistry'; +export const ProblemPatternRegistry = Symbol('ProblemPatternRegistry'); +export interface ProblemPatternRegistry { + onReady(): Promise; + register(key: string, value: ProblemPatternContribution | ProblemPatternContribution[]): Promise; + get(key: string): Promise; +} + +export const problemMatcherPath = '/services/problemMatcherRegistry'; +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..0c86ec8369d27 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); + }, + onTaskOutputProcessed(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..afe980aacbf21 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.onData((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..d5aa725885800 --- /dev/null +++ b/packages/task/src/node/task-abstract-line-matcher.ts @@ -0,0 +1,288 @@ +/******************************************************************************** + * 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) { + fullPath = new URI(matcher.filePrefix).resolve(filename).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 regexMatches = this.activePattern.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..b034536b887dc 100644 --- a/packages/task/src/node/task-backend-module.ts +++ b/packages/task/src/node/task-backend-module.ts @@ -24,12 +24,41 @@ 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, + problemMatcherPath, ProblemMatcherRegistry, + problemPatternPath, ProblemPatternRegistry, + taskDefinitionPath, TaskDefinitionRegistry +} from '../common'; +import { TaskDefinitionRegistryImpl } from './task-definition-registry'; +import { ProblemMatcherRegistryImpl } from './task-problem-matcher-registry'; +import { ProblemPatternRegistryImpl } from './task-problem-pattern-registry'; export default new ContainerModule(bind => { bind(TaskManager).toSelf().inSingletonScope(); bind(BackendApplicationContribution).toService(TaskManager); + + bind(TaskDefinitionRegistry).to(TaskDefinitionRegistryImpl).inSingletonScope(); + bind(ProblemMatcherRegistry).to(ProblemMatcherRegistryImpl).inSingletonScope(); + bind(ProblemPatternRegistry).to(ProblemPatternRegistryImpl).inSingletonScope(); + + bind(ConnectionHandler).toDynamicValue(ctx => + new JsonRpcConnectionHandler(taskDefinitionPath, () => + ctx.container.get(TaskDefinitionRegistry) + ) + ).inSingletonScope(); + bind(ConnectionHandler).toDynamicValue(ctx => + new JsonRpcConnectionHandler(problemMatcherPath, () => + ctx.container.get(ProblemMatcherRegistry) + ) + ).inSingletonScope(); + bind(ConnectionHandler).toDynamicValue(ctx => + new JsonRpcConnectionHandler(problemPatternPath, () => + ctx.container.get(ProblemPatternRegistry) + ) + ).inSingletonScope(); + bind(TaskServer).to(TaskServerImpl).inSingletonScope(); bind(ConnectionHandler).toDynamicValue(ctx => new JsonRpcConnectionHandler(taskPath, client => { diff --git a/packages/task/src/node/task-definition-registry.ts b/packages/task/src/node/task-definition-registry.ts new file mode 100644 index 0000000000000..5a38a42f1e2f8 --- /dev/null +++ b/packages/task/src/node/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; + } + + async register(definitionContribution: TaskDefinitionContribution, pluginId: string): Promise { + 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/node/task-line-matchers.ts b/packages/task/src/node/task-line-matchers.ts new file mode 100644 index 0000000000000..8b0c35481b7dc --- /dev/null +++ b/packages/task/src/node/task-line-matchers.ts @@ -0,0 +1,117 @@ +/******************************************************************************** + * 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 regexMatches = this.beginsPattern.regexp.exec(line); + if (regexMatches) { + this.fillProblemData(this.cachedProblemData, this.beginsPattern, regexMatches); + this.resetActivePatternIndex(0); + return true; + } + return false; + } + + private matchEnd(line: string): boolean { + const match = this.endsPattern.regexp.exec(line); + if (match) { + this.resetActivePatternIndex(-1); + return true; + } + return false; + } +} 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-problem-matcher-registry.ts b/packages/task/src/node/task-problem-matcher-registry.ts new file mode 100644 index 0000000000000..8ed10ce71624d --- /dev/null +++ b/packages/task/src/node/task-problem-matcher-registry.ts @@ -0,0 +1,218 @@ +/******************************************************************************** + * 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') { + const registeredPattern = await 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 async fillDefaults(): Promise { + this.add({ + name: 'msCompile', + label: 'Microsoft compiler problems', + owner: 'msCompile', + applyTo: ApplyToKind.allDocuments, + fileLocation: FileLocationKind.Absolute, + pattern: (await this.problemPatternRegistry.get('msCompile'))! + }); + + this.add({ + name: 'lessCompile', + label: 'Less problems', + deprecated: true, + owner: 'lessCompile', + source: 'less', + applyTo: ApplyToKind.allDocuments, + fileLocation: FileLocationKind.Absolute, + pattern: (await 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: (await this.problemPatternRegistry.get('gulp-tsc'))! + }); + + this.add({ + name: 'jshint', + label: 'JSHint problems', + owner: 'jshint', + source: 'jshint', + applyTo: ApplyToKind.allDocuments, + fileLocation: FileLocationKind.Absolute, + pattern: (await this.problemPatternRegistry.get('jshint'))! + }); + + this.add({ + name: 'jshint-stylish', + label: 'JSHint stylish problems', + owner: 'jshint', + source: 'jshint', + applyTo: ApplyToKind.allDocuments, + fileLocation: FileLocationKind.Absolute, + pattern: (await 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: (await 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: (await 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: (await this.problemPatternRegistry.get('go'))! + }); + } +} diff --git a/packages/task/src/node/task-problem-pattern-registry.ts b/packages/task/src/node/task-problem-pattern-registry.ts new file mode 100644 index 0000000000000..5f31caaffd08d --- /dev/null +++ b/packages/task/src/node/task-problem-pattern-registry.ts @@ -0,0 +1,180 @@ +/******************************************************************************** + * 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; + } + + async register(key: string, value: ProblemPatternContribution | ProblemPatternContribution[]): Promise { + 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); + } + } + + async get(key: string): Promise { + // TODO await readypromise ? + 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*(.*)$/, + 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+(.*)$/, + 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*(.*)$/, + 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*(.*)$/, + 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*(.*)$/, + kind: ProblemLocationKind.Location, + file: 1, + location: 2, + severity: 3, + code: 4, + message: 5 + }); + this.add('lessCompile', { + regexp: /^\s*(.*) in file (.*) line no. (\d+)$/, + 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+)\))?$/, + kind: ProblemLocationKind.Location, + file: 1, + line: 2, + character: 3, + message: 4, + severity: 5, + code: 6 + }); + this.add('jshint-stylish', [ + { + regexp: /^(.+)$/, + kind: ProblemLocationKind.Location, + file: 1 + }, + { + regexp: /^\s+line\s+(\d+)\s+col\s+(\d+)\s+(.+?)(?:\s+\((\w)(\d+)\))?$/, + 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\((.+)\)$/, + file: 1, + kind: ProblemLocationKind.Location, + line: 2, + character: 3, + severity: 4, + message: 5, + code: 6 + }); + this.add('eslint-stylish', [ + { + regexp: /^([^\s].*)$/, + kind: ProblemLocationKind.Location, + file: 1 + }, + { + regexp: /^\s+(\d+):(\d+)\s+(error|warning|info)\s+(.+?)(?:\s\s+(.*))?$/, + line: 1, + character: 2, + severity: 3, + message: 4, + code: 5, + loop: true + } + ]); + this.add('go', { + regexp: /^([^:]*: )?((.:)?[^:]*):(\d+)(:(\d+))?: (.*)$/, + kind: ProblemLocationKind.Location, + file: 2, + line: 4, + character: 6, + message: 7 + }); + } +} diff --git a/packages/task/src/node/task-server.ts b/packages/task/src/node/task-server.ts index 6f615b21c8302..97b37605969a1 100644 --- a/packages/task/src/node/task-server.ts +++ b/packages/task/src/node/task-server.ts @@ -16,9 +16,22 @@ 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, + TaskDefinitionRegistry, + RunTaskOption, + ProblemMatcher, + ProblemMatcherContribution, + ProblemMatcherRegistry +} 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 +48,15 @@ export class TaskServerImpl implements TaskServer { @inject(TaskRunnerRegistry) protected readonly runnerRegistry: TaskRunnerRegistry; + @inject(TaskDefinitionRegistry) + protected readonly taskDefinitionRegistry: TaskDefinitionRegistry; + + @inject(ProblemMatcherRegistry) + protected readonly problemMatcherRegistry: ProblemMatcherRegistry; + + /** task context - {task id - problem collector} */ + private problemCollectors: Map> = new Map(); + dispose() { // do nothing } @@ -53,7 +75,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 +84,41 @@ export class TaskServerImpl implements TaskServer { this.fireTaskExitedEvent(event); }); + const problemMatchers = this.getProblemMatchers(taskConfiguration, option); + task.onOutput(async event => { + let collector: ProblemCollector | undefined = this.getCachedProblemCollector(event.ctx || '', event.taskId); + if (!collector) { + await this.problemMatcherRegistry.onReady(); + const matchers: ProblemMatcher[] = []; + for (const matcher of problemMatchers) { + let resolvedMatcher: ProblemMatcher | undefined; + if (typeof matcher === 'string') { + resolvedMatcher = this.problemMatcherRegistry.get(matcher); + } else { + resolvedMatcher = await this.problemMatcherRegistry.getProblemMatcherFromContribution(matcher); + } + if (resolvedMatcher) { + matchers.push(resolvedMatcher); + } + } + collector = new ProblemCollector(matchers); + 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; @@ -71,6 +128,27 @@ export class TaskServerImpl implements TaskServer { return this.runnerRegistry.getRunnerTypes(); } + private getProblemMatchers(taskConfiguration: TaskConfiguration, option?: RunTaskOption): (string | ProblemMatcherContribution)[] { + const hasCustomization = option && option.customizations && option.customizations.length > 0; + const problemMatchers: (string | ProblemMatcherContribution)[] = []; + if (hasCustomization) { + const taskDefinition = this.taskDefinitionRegistry.getDefinition(taskConfiguration); + if (taskDefinition) { + const cus = option!.customizations!.filter(customization => + taskDefinition.properties.required.every(rp => customization[rp] === taskConfiguration[rp]) + )[0]; + if (cus && cus.problemMatcher) { + if (Array.isArray(cus.problemMatcher)) { + problemMatchers.push(...cus.problemMatcher); + } else { + problemMatchers.push(cus.problemMatcher); + } + } + } + } + return problemMatchers; + } + protected fireTaskExitedEvent(event: TaskExitedEvent) { this.logger.debug(log => log('task has exited:', event)); @@ -87,6 +165,10 @@ export class TaskServerImpl implements TaskServer { }); } + protected fireTaskOutputProcessedEvent(event: TaskOutputProcessedEvent) { + this.clients.forEach(client => client.onTaskOutputProcessed(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 +195,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;