From b6218197df7d2728fb650788c71880a5937baac9 Mon Sep 17 00:00:00 2001 From: elaihau Date: Mon, 18 Mar 2019 11:25:14 -0400 Subject: [PATCH] wip create problem marker from task output --- .../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 | 25 +- packages/plugin-ext/src/plugin/types-impl.ts | 6 +- packages/process/src/node/process.ts | 3 + packages/process/src/node/raw-process.ts | 8 + packages/process/src/node/terminal-process.ts | 8 + packages/task/package.json | 3 +- .../browser/process/process-task-resolver.ts | 6 +- .../task/src/browser/task-configurations.ts | 53 ++- .../task/src/browser/task-frontend-module.ts | 16 +- packages/task/src/browser/task-service.ts | 32 +- packages/task/src/common/index.ts | 1 + .../src/common/problem-matcher-protocol.ts | 194 +++++++++++ packages/task/src/common/task-protocol.ts | 118 ++++++- packages/task/src/common/task-watcher.ts | 10 +- .../task/src/node/process/process-task.ts | 69 +++- packages/task/src/node/task-backend-module.ts | 31 +- .../task/src/node/task-definition-registry.ts | 65 ++++ .../task/src/node/task-problem-collector.ts | 308 ++++++++++++++++++ .../src/node/task-problem-matcher-registry.ts | 195 +++++++++++ .../src/node/task-problem-pattern-registry.ts | 185 +++++++++++ packages/task/src/node/task-server.ts | 94 +++++- packages/task/src/node/task.ts | 19 +- 25 files changed, 1428 insertions(+), 43 deletions(-) create mode 100644 packages/task/src/common/problem-matcher-protocol.ts create mode 100644 packages/task/src/node/task-definition-registry.ts create mode 100644 packages/task/src/node/task-problem-collector.ts create mode 100644 packages/task/src/node/task-problem-matcher-registry.ts create mode 100644 packages/task/src/node/task-problem-pattern-registry.ts 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 a8c5665abe017..0bbaa2bfd3860 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin.ts @@ -199,7 +199,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..10ee7c1af21bb 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,19 @@ export class PluginContributionHandler { }); } } + + if (contributions.taskDefinitions) { + contributions.taskDefinitions.forEach(def => this.taskDefinitionRegistry.register(def, modelId)); + } + + if (contributions.problemMatchers) { + contributions.problemMatchers.forEach(matcher => this.problemMatcherRegistry.register(matcher)); + } + + if (contributions.problemPatterns) { + contributions.problemPatterns.forEach(pattern => this.problemPatternRegistry.register(pattern.name!, pattern)); + // TODO consider creating NamedProblemPattern interface + } } protected pluginCommandIconId = 0; diff --git a/packages/plugin-ext/src/plugin/types-impl.ts b/packages/plugin-ext/src/plugin/types-impl.ts index 2191a1d3fecfd..8b267b482d033 100644 --- a/packages/plugin-ext/src/plugin/types-impl.ts +++ b/packages/plugin-ext/src/plugin/types-impl.ts @@ -1686,12 +1686,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 da1fd1c4d0a40..1b598192c4239 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 e32f3793da3b0..0443cd360a4d6 100644 --- a/packages/process/src/node/raw-process.ts +++ b/packages/process/src/node/raw-process.ts @@ -142,6 +142,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 80abf8c165d3a..0720c2316d710 100644 --- a/packages/process/src/node/terminal-process.ts +++ b/packages/process/src/node/terminal-process.ts @@ -121,4 +121,12 @@ 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); + // this.ringBuffer.getStream().on('close', listener); // TODO check if we should listen to the `close` from the buffer + } } diff --git a/packages/task/package.json b/packages/task/package.json index 77254e4e987d5..9395dd093e945 100644 --- a/packages/task/package.json +++ b/packages/task/package.json @@ -10,7 +10,8 @@ "@theia/terminal": "^0.5.0", "@theia/variable-resolver": "^0.5.0", "@theia/workspace": "^0.5.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/process/process-task-resolver.ts b/packages/task/src/browser/process/process-task-resolver.ts index bcde38b1eb5b3..c4d37c8505858 100644 --- a/packages/task/src/browser/process/process-task-resolver.ts +++ b/packages/task/src/browser/process/process-task-resolver.ts @@ -41,13 +41,9 @@ export class ProcessTaskResolver implements TaskResolver { const options = { context: new URI(taskConfig._source).withScheme('file') }; const processTaskConfig = taskConfig as ProcessTaskConfiguration; const result: ProcessTaskConfiguration = { - type: processTaskConfig.type, - _source: processTaskConfig._source, - _scope: processTaskConfig._scope, - label: processTaskConfig.label, + ...processTaskConfig, command: await this.variableResolverService.resolve(processTaskConfig.command, options), args: processTaskConfig.args ? await this.variableResolverService.resolveArray(processTaskConfig.args, options) : undefined, - options: processTaskConfig.options, windows: processTaskConfig.windows ? { command: await this.variableResolverService.resolve(processTaskConfig.windows.command, options), args: processTaskConfig.windows.args ? await this.variableResolverService.resolveArray(processTaskConfig.windows.args, options) : undefined, diff --git a/packages/task/src/browser/task-configurations.ts b/packages/task/src/browser/task-configurations.ts index c84bbbd0c5dae..ecdb7090fe0b4 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, TaskCutomization } from '../common/task-protocol'; 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: TaskCutomization[] = []; + protected watchedConfigFileUris: string[] = []; protected watchersMap = new Map(); // map of watchers for task config files, where the key is folder uri @@ -173,6 +175,10 @@ export class TaskConfigurations implements Disposable { this.tasksMap.delete(source); } + getTaskCustomizations(type: string): TaskCutomization[] { + 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,23 +204,29 @@ 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 */ @@ -234,7 +246,10 @@ export class TaskConfigurations implements Disposable { 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: this.getSourceFolderFromConfigUri(uri) })); + return this.filterDuplicates( + // tslint:disable-next-line:no-any + (tasks['tasks'] as Array).filter(t => TaskConfiguration.is(t)) + ).map(t => Object.assign(t, { _source: this.getSourceFolderFromConfigUri(uri) })); } } catch (err) { console.error(`Error(s) reading config file: ${uri}`); @@ -301,4 +316,30 @@ export class TaskConfigurations implements Disposable { private getSourceFolderFromConfigUri(configFileUri: string): string { return new URI(configFileUri).parent.parent.path.toString(); } + + // TODO put the file read logic into a separate function and reuse + protected async readTaskCustomizations(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 { + // tslint:disable-next-line:no-any + return (tasks['tasks'] as Array).filter(t => !TaskConfiguration.is(t)); + } + } 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 a0e7d2108e544..b38e7a29e37e2 100644 --- a/packages/task/src/browser/task-service.ts +++ b/packages/task/src/browser/task-service.ts @@ -24,9 +24,10 @@ import { TerminalService } from '@theia/terminal/lib/browser/base/terminal-servi import { TerminalWidget } from '@theia/terminal/lib/browser/base/terminal-widget'; import { WidgetManager } from '@theia/core/lib/browser/widget-manager'; 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 { TaskServer, TaskExitedEvent, TaskInfo, TaskConfiguration, TaskOutputProcessedEvent, RunTaskOption } from '../common/task-protocol'; import { TaskWatcher } from '../common/task-watcher'; import { TaskConfigurationClient, TaskConfigurations } from './task-configurations'; import { ProvidedTaskConfigurations } from './provided-task-configurations'; @@ -88,6 +89,9 @@ export class TaskService implements TaskConfigurationClient { @inject(EditorManager) protected readonly editorManager: EditorManager; + @inject(ProblemManager) + protected readonly problemManager: ProblemManager; + /** * @deprecated To be removed in 0.5.0 */ @@ -115,6 +119,19 @@ export class TaskService implements TaskConfigurationClient { } }); + // TODO should listen to the close event of terminal to dispose the collector & markers ? + this.taskWatcher.onOutputProcessed((event: TaskOutputProcessedEvent) => { + if (!this.isEventForThisClient(event.ctx)) { + return; + } + if (event.problems) { + event.problems.forEach(problem => { + const uri = new URI(problem.resource.toString()); + this.problemManager.setMarkers(uri, problem.marker.source || 'task', [problem.marker]); + }); + } + }); + // notify user that task has finished this.taskWatcher.onTaskExit((event: TaskExitedEvent) => { if (!this.isEventForThisClient(event.ctx)) { @@ -210,19 +227,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; @@ -238,7 +260,7 @@ export class TaskService implements TaskConfigurationClient { 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}`; 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..909ce81bc032a --- /dev/null +++ b/packages/task/src/common/problem-matcher-protocol.ts @@ -0,0 +1,194 @@ +/******************************************************************************** + * 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 { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver-types'; +import vscodeURI from 'vscode-uri/lib/umd'; + +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 interface WatchingMatcher { + activeOnStart: boolean; + beginsPattern: WatchingPattern; + endsPattern: WatchingPattern; +} + +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 { + name: string; // TODO should the first 3 props be optional ? + label: string; + deprecated?: boolean; + + owner: string; + source?: string; + applyTo: ApplyToKind; + fileLocation: FileLocationKind; + filePrefix?: string; + pattern: ProblemPattern | ProblemPattern[]; // TODO how do we know which pattern to use if support an array of patterns ? + severity?: Severity; // TODO both matcher and pattern have "severity" prop, are they the same thing ? which one to use ? + watching?: WatchingMatcher; + uriProvider?: (path: string) => vscodeURI; +} + +export interface ProblemPattern { + name?: string; // TODO should the name be optional ? + + 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 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; + marker: Diagnostic; + description: ProblemMatcher; +} diff --git a/packages/task/src/common/task-protocol.ts b/packages/task/src/common/task-protocol.ts index 20e5319fc54a4..faaf71d19bff1 100644 --- a/packages/task/src/common/task-protocol.ts +++ b/packages/task/src/common/task-protocol.ts @@ -15,6 +15,9 @@ ********************************************************************************/ import { JsonRpcServer } from '@theia/core/lib/common/messaging/proxy-factory'; +import { ProblemPattern, ProblemMatcher, ProblemMatch } from './problem-matcher-protocol'; +// import { Diagnostic } from 'vscode-languageserver-types'; +// import URI from '@theia/core/lib/common/uri'; export const taskPath = '/services/task'; @@ -40,6 +43,12 @@ export interface TaskConfiguration { // tslint:disable-next-line:no-any readonly [key: string]: any; } +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 { @@ -58,7 +67,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; /** @@ -76,6 +85,10 @@ export interface TaskServer extends JsonRpcServer { } +export interface RunTaskOption { + customizations?: TaskCutomization[]; +} + /** Event sent when a task has concluded its execution */ export interface TaskExitedEvent { readonly taskId: number; @@ -86,7 +99,110 @@ export interface TaskExitedEvent { readonly signal?: string; } +export interface TaskOutputEvent { + readonly taskId: number; + readonly ctx?: string; + readonly terminalId?: number; + readonly line: string; +} + +export interface TaskOutputProcessedEvent { + readonly taskId: number; + readonly ctx?: string; + readonly terminalId?: number; + 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 TaskCutomization { + type: string; + problemMatcher?: string[]; + // tslint:disable-next-line:no-any + [name: string]: any; +} + +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 ProblemMatcherContribution { + name: string; + label: string; + deprecated?: boolean; + + owner: string; + source?: string; + applyTo: string; + fileLocation?: string | string[]; + filePrefix?: string; + pattern?: string | string[]; + severity?: string; + // TODO investigate if "watching" or "uriProvider" should be added to this interface +} + +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): void; +} + +export const problemPatternPath = '/services/problemPatternRegistry'; +export const ProblemPatternRegistry = Symbol('ProblemPatternRegistry'); +export interface ProblemPatternRegistry { + onReady(): Promise; + register(key: string, value: ProblemPatternContribution | ProblemPatternContribution[]): void; + add(key: string, value: ProblemPattern | ProblemPattern[]): void; + get(key: string): ProblemPattern | ProblemPattern[]; +} + +export const problemMatcherPath = '/services/problemMatcherRegistry'; +export const ProblemMatcherRegistry = Symbol('ProblemMatcherRegistry'); +export interface ProblemMatcherRegistry { + onReady(): Promise; + register(matcher: ProblemMatcherContribution): void; + add(matcher: ProblemMatcher): void; + get(name: string): ProblemMatcher; + keys(): string[]; } 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..53eb600131ad9 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,35 @@ 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, + // terminalId?: number; // TODO do we need terminal id? + 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 +104,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 +121,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-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..36096302468aa --- /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; + } + + register(definitionContribution: TaskDefinitionContribution, pluginId: string) { + 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-problem-collector.ts b/packages/task/src/node/task-problem-collector.ts new file mode 100644 index 0000000000000..f12825edd726e --- /dev/null +++ b/packages/task/src/node/task-problem-collector.ts @@ -0,0 +1,308 @@ +/******************************************************************************** + * 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 { isWindows } from '@theia/core/lib/common/os'; +import { Diagnostic, DiagnosticSeverity, Range } from 'vscode-languageserver-types'; +import { + FileLocationKind, ProblemMatcher, ProblemPattern, ProblemData, + ProblemMatch, 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 class ProblemCollector { // TODO consider implement toDispose() to remove markers when needed + + // private owners: string[]; + private lineMatchers: LineMatcher[] = []; + + constructor( + protected problemMatchers: ProblemMatcher[] + ) { + // const ownersSet = new Set(); + this.lineMatchers = problemMatchers.map(matcher => // { + // if (matcher.owner) { + // ownersSet.add(matcher.owner); + // } + // return this.createLineMatcher(matcher); + this.createLineMatcher(matcher) + // }); + ); + // this.owners = Array.from(ownersSet.values()); + } + + processLines(lines: string[]): ProblemMatch[] { + const markers: ProblemMatch[] = []; + this.lineMatchers.forEach(lineMatcher => { + const match = lineMatcher.match(lines); + if (match) { + markers.push(...match); + } + }); + return markers; + } + + private createLineMatcher(problemMatcher: ProblemMatcher): LineMatcher { + return new LineMatcher(problemMatcher); + } + + private lineBuffer: string[] = []; + processLine(line: string): ProblemMatch[] { + this.lineBuffer.push(line); + const markers: ProblemMatch[] = []; + this.lineMatchers.forEach(lineMatcher => { + if (this.lineBuffer.length >= lineMatcher.patternCount) { + const match = lineMatcher.match(this.lineBuffer.slice(this.lineBuffer.length - lineMatcher.patternCount)); + if (match) { + markers.push(...match); + } + } + }); + return markers; + } +} + +export class LineMatcher { + + protected patterns: ProblemPattern[] = []; + + constructor( + protected matcher: ProblemMatcher + ) { + if (Array.isArray(matcher.pattern)) { + this.patterns = matcher.pattern; + } else { + this.patterns = [matcher.pattern]; + } + } + + match(lines: string[]): ProblemMatch[] { + const matches: ProblemMatch[] = []; + const bufferSize = this.patterns.length; + for (let startLine = 0; startLine + bufferSize <= lines.length; startLine++) { + const buffer = lines.slice(startLine, startLine + bufferSize); + const oneMatch = this.doOneMatch(buffer); + if (oneMatch) { + matches.push(oneMatch); + } + } + return matches; + } + + get patternCount() { + return this.patterns.length; + } + + protected doOneMatch(lines: string[]): ProblemMatch | undefined { + // tslint:disable-next-line:no-null-keyword + const data: ProblemData = Object.create(null); + for (let ind = 0; ind < lines.length; ind++) { + const line = lines[ind]; + const pattern = this.patterns[ind]; + + if (pattern.kind !== undefined && data.kind !== undefined) { + data.kind = pattern.kind; + } + const regexMatches = pattern.regexp.exec(line); + if (regexMatches) { + this.fillProblemData(data, pattern, regexMatches); + + } + } + const matchesResult = this.getMarkerMatch(data); + return matchesResult; + } + + 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; + } else { + 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 + }; + } + } 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); + } + } + +} 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..2c644630c9ac6 --- /dev/null +++ b/packages/task/src/node/task-problem-matcher-registry.ts @@ -0,0 +1,195 @@ +/******************************************************************************** + * 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 { inject, injectable, postConstruct } from 'inversify'; +import { + ApplyToKind, FileLocationKind, ProblemMatcher, Severity, ProblemMatcherRegistry, ProblemPatternRegistry, ProblemMatcherContribution +} from '../common'; + +@injectable() +export class ProblemMatcherRegistryImpl implements ProblemMatcherRegistry { + + private matchers: { [name: string]: ProblemMatcher }; + 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.onProblemPatternRegistryReady().then(() => { + this.fillDefaults(); + }); + // TODO rework the ready promise + // TODO this registry should not be ready until all plugins finish registering matchers + this.readyPromise = new Promise((res, rej) => res(undefined)); + } + + onReady(): Promise { + return this.onProblemPatternRegistryReady().then(() => this.readyPromise); + } + + add(matcher: ProblemMatcher): void { + this.matchers[matcher.name] = matcher; + } + + register(matcher: ProblemMatcherContribution): void { + const { fileLocation, filePrefix } = this.getFileLocationKindAndPrefix(matcher); + 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: [], // TODO string or object ? + severity: Severity.fromValue(matcher.severity) + }; + this.add(problemMatcher); + } + + get(name: string): ProblemMatcher { + if (name.startsWith('$')) { + return this.matchers[name.slice(1)]; + } + return this.matchers[name]; + } + + keys(): string[] { + return Object.keys(this.matchers); + } + + 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 }; + } + + private onProblemPatternRegistryReady(): Promise { + return this.problemPatternRegistry.onReady(); + } + + 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/node/task-problem-pattern-registry.ts b/packages/task/src/node/task-problem-pattern-registry.ts new file mode 100644 index 0000000000000..023a833c56926 --- /dev/null +++ b/packages/task/src/node/task-problem-pattern-registry.ts @@ -0,0 +1,185 @@ +/******************************************************************************** + * 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, 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(); + // TODO rework the ready promise + // TODO this registry should not be ready until all plugins finish registering patterns + this.readyPromise = new Promise((res, rej) => res(undefined)); + } + + onReady(): Promise { + return this.readyPromise; + } + + add(key: string, value: ProblemPattern | ProblemPattern[]): void { + this.patterns[key] = value; + } + + register(key: string, value: ProblemPatternContribution | ProblemPatternContribution[]): void { + if (Array.isArray(value)) { + value.forEach(problemPatternContribution => this.register(key, problemPatternContribution)); + } else { + const problemPattern = { + 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 + }; + this.add(key, problemPattern); + } + } + + get(key: string): ProblemPattern | ProblemPattern[] { + return this.patterns[key]; + } + + 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..d90cbd8adf993 100644 --- a/packages/task/src/node/task-server.ts +++ b/packages/task/src/node/task-server.ts @@ -16,9 +16,20 @@ 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, + ProblemMatcherRegistry +} from '../common/task-protocol'; import { TaskManager } from './task-manager'; import { TaskRunnerRegistry } from './task-runner'; +import { ProblemCollector } from './task-problem-collector'; @injectable() export class TaskServerImpl implements TaskServer { @@ -35,6 +46,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 +73,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 +82,29 @@ export class TaskServerImpl implements TaskServer { this.fireTaskExitedEvent(event); }); + const problemMatchers = this.getProblemMatchers(taskConfiguration, option); + task.onOutput(event => { + let collector: ProblemCollector | undefined = this.getCachedProblemCollector(event.ctx || '', event.taskId); + if (!collector) { + collector = new ProblemCollector(problemMatchers.map(m => this.problemMatcherRegistry.get(m))); + 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, + terminalId: event.terminalId, + problems + }); + } + }); + + task.onExit(event => { + this.removedCachedProblemCollector(event.ctx || '', event.taskId); + }); + const taskInfo = await task.getRuntimeInfo(); this.fireTaskCreatedEvent(taskInfo); return taskInfo; @@ -71,6 +114,23 @@ export class TaskServerImpl implements TaskServer { return this.runnerRegistry.getRunnerTypes(); } + private getProblemMatchers(taskConfiguration: TaskConfiguration, option?: RunTaskOption): string[] { + const hasCustomization = option && option.customizations && option.customizations.length > 0; + const problemMatchers: string[] = []; + 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 && cus.problemMatcher.length > 0) { + problemMatchers.push(...cus.problemMatcher); + } + } + } + return problemMatchers; + } + protected fireTaskExitedEvent(event: TaskExitedEvent) { this.logger.debug(log => log('task has exited:', event)); @@ -87,6 +147,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 +177,30 @@ 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)) { + // do nothing + } else { + 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;