diff --git a/CHANGELOG.md b/CHANGELOG.md index 70c00c9f07266..391bb3770b5ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## v0.11.0 - [task] added `tasks.fetchTasks()` and `tasks.executeTask()` to plugins API [#6058](https://github.com/theia-ide/theia/pull/6058) +- [task] prompt user to choose parser to parse task output [#5877](https://github.com/theia-ide/theia/pull/5877) Breaking changes: diff --git a/packages/task/src/browser/task-configurations.ts b/packages/task/src/browser/task-configurations.ts index 43a68daef175a..91c00716a612e 100644 --- a/packages/task/src/browser/task-configurations.ts +++ b/packages/task/src/browser/task-configurations.ts @@ -324,27 +324,26 @@ export class TaskConfigurations implements Disposable { for (const e of errors) { console.error(`Error parsing ${uri}: error: ${e.error}, length: ${e.length}, offset: ${e.offset}`); } + } + const rootFolderUri = this.getSourceFolderFromConfigUri(uri); + if (this.rawTaskConfigurations.has(rootFolderUri)) { + this.rawTaskConfigurations.delete(rootFolderUri); + } + if (rawTasks && rawTasks['tasks']) { + const tasks = rawTasks['tasks'].map((t: TaskCustomization | TaskConfiguration) => { + if (this.isDetectedTask(t)) { + const def = this.getTaskDefinition(t); + return Object.assign(t, { + _source: def!.source, + _scope: this.getSourceFolderFromConfigUri(uri) + }); + } + return Object.assign(t, { _source: this.getSourceFolderFromConfigUri(uri) }); + }); + this.rawTaskConfigurations.set(rootFolderUri, tasks); + return tasks; } else { - const rootFolderUri = this.getSourceFolderFromConfigUri(uri); - if (this.rawTaskConfigurations.has(rootFolderUri)) { - this.rawTaskConfigurations.delete(rootFolderUri); - } - if (rawTasks && rawTasks['tasks']) { - const tasks = rawTasks['tasks'].map((t: TaskCustomization | TaskConfiguration) => { - if (this.isDetectedTask(t)) { - const def = this.getTaskDefinition(t); - return Object.assign(t, { - _source: def!.source, - _scope: this.getSourceFolderFromConfigUri(uri) - }); - } - return Object.assign(t, { _source: this.getSourceFolderFromConfigUri(uri) }); - }); - this.rawTaskConfigurations.set(rootFolderUri, tasks); - return tasks; - } else { - return []; - } + return []; } } catch (err) { console.error(`Error(s) reading config file: ${uri}`); @@ -359,13 +358,7 @@ export class TaskConfigurations implements Disposable { return; } - const isDetectedTask = this.isDetectedTask(task); - let sourceFolderUri: string | undefined; - if (isDetectedTask) { - sourceFolderUri = task._scope; - } else { - sourceFolderUri = task._source; - } + const sourceFolderUri: string | undefined = this.getSourceFolderUriFromTask(task); if (!sourceFolderUri) { console.error('Global task cannot be customized'); return; @@ -396,9 +389,25 @@ export class TaskConfigurations implements Disposable { customization[p] = task[p]; } }); + const problemMatcher: string[] = []; + if (task.problemMatcher) { + if (Array.isArray(task.problemMatcher)) { + problemMatcher.push(...task.problemMatcher.map(t => { + if (typeof t === 'string') { + return t; + } else { + return t.name!; + } + })); + } else if (typeof task.problemMatcher === 'string') { + problemMatcher.push(task.problemMatcher); + } else { + problemMatcher.push(task.problemMatcher.name!); + } + } return { ...customization, - problemMatcher: [] + problemMatcher: problemMatcher.map(name => name.startsWith('$') ? name : `$${name}`) }; } @@ -465,6 +474,59 @@ export class TaskConfigurations implements Disposable { this.tasksMap = newTaskMap; } + /** + * saves the names of the problem matchers to be used to parse the output of the given task to `tasks.json` + * @param task task that the problem matcher(s) are applied to + * @param problemMatchers name(s) of the problem matcher(s) + */ + async saveProblemMatcherForTask(task: TaskConfiguration, problemMatchers: string[]): Promise { + const sourceFolderUri: string | undefined = this.getSourceFolderUriFromTask(task); + if (!sourceFolderUri) { + console.error('Global task cannot be customized'); + return; + } + const configFileUri = this.getConfigFileUri(sourceFolderUri); + const configuredAndCustomizedTasks = await this.getTasks(); + if (configuredAndCustomizedTasks.some(t => this.taskDefinitionRegistry.compareTasks(t, task))) { // task is already in `tasks.json` + try { + const content = (await this.fileSystem.resolveContent(configFileUri)).content; + const errors: ParseError[] = []; + const jsonTasks = jsoncparser.parse(content, errors).tasks; + if (errors.length > 0) { + for (const e of errors) { + console.error(`Error parsing ${configFileUri}: error: ${e.error}, length: ${e.length}, offset: ${e.offset}`); + } + } + if (jsonTasks) { + const ind = jsonTasks.findIndex((t: TaskConfiguration) => { + if (t.type !== (task.taskType || task.type)) { + return false; + } + const def = this.taskDefinitionRegistry.getDefinition(t); + if (def) { + return def.properties.all.every(p => t[p] === task[p]); + } + return t.label === task.label; + }); + const newTask = Object.assign(jsonTasks[ind], { problemMatcher: problemMatchers.map(name => name.startsWith('$') ? name : `$${name}`) }); + jsonTasks[ind] = newTask; + } + const updatedTasks = JSON.stringify({ tasks: jsonTasks }); + const formattingOptions = { tabSize: 4, insertSpaces: true, eol: '' }; + const edits = jsoncparser.format(updatedTasks, undefined, formattingOptions); + const updatedContent = jsoncparser.applyEdits(updatedTasks, edits); + const resource = await this.resourceProvider(new URI(configFileUri)); + Resource.save(resource, { content: updatedContent }); + } catch (e) { + console.error(`Failed to save task configuration for ${task.label} task. ${e.toString()}`); + return; + } + } else { // task is not in `tasks.json` + task.problemMatcher = problemMatchers; + this.saveTask(configFileUri, task); + } + } + private getSourceFolderFromConfigUri(configFileUri: string): string { return new URI(configFileUri).parent.parent.path.toString(); } @@ -482,4 +544,15 @@ export class TaskConfigurations implements Disposable { type: task.taskType || task.type }); } + + private getSourceFolderUriFromTask(task: TaskConfiguration): string | undefined { + const isDetectedTask = this.isDetectedTask(task); + let sourceFolderUri: string | undefined; + if (isDetectedTask) { + sourceFolderUri = task._scope; + } else { + sourceFolderUri = task._source; + } + return sourceFolderUri; + } } diff --git a/packages/task/src/browser/task-problem-matcher-registry.ts b/packages/task/src/browser/task-problem-matcher-registry.ts index c73e73ed81c33..56fb06e6472b8 100644 --- a/packages/task/src/browser/task-problem-matcher-registry.ts +++ b/packages/task/src/browser/task-problem-matcher-registry.ts @@ -76,6 +76,18 @@ export class ProblemMatcherRegistry { return this.matchers[name]; } + /** + * Returns all registered problem matchers in the registry. + */ + getAll(): NamedProblemMatcher[] { + const all: NamedProblemMatcher[] = []; + for (const matcherName of Object.keys(this.matchers)) { + all.push(this.get(matcherName)!); + } + all.sort((one, other) => one.name.localeCompare(other.name)); + return all; + } + /** * Transforms the `ProblemMatcherContribution` to a `ProblemMatcher` * diff --git a/packages/task/src/browser/task-service.ts b/packages/task/src/browser/task-service.ts index e04e1a60f22a4..acfcf6b350255 100644 --- a/packages/task/src/browser/task-service.ts +++ b/packages/task/src/browser/task-service.ts @@ -18,23 +18,26 @@ import { inject, injectable, named, postConstruct } from 'inversify'; import { EditorManager } from '@theia/editor/lib/browser'; import { ILogger } from '@theia/core/lib/common'; import { ApplicationShell, FrontendApplication, WidgetManager } from '@theia/core/lib/browser'; +import { QuickPickService, QuickPickItem } from '@theia/core/lib/common/quick-pick-service'; import { TaskResolverRegistry, TaskProviderRegistry } from './task-contribution'; import { TERMINAL_WIDGET_FACTORY_ID, TerminalWidgetFactoryOptions } from '@theia/terminal/lib/browser/terminal-widget-impl'; 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 { OpenerService, open } from '@theia/core/lib/browser/opener-service'; 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 { + NamedProblemMatcher, ProblemMatcher, ProblemMatchData, + TaskConfiguration, TaskCustomization, - TaskServer, TaskExitedEvent, TaskInfo, - TaskConfiguration, TaskOutputProcessedEvent, + TaskServer, RunTaskOption } from '../common'; import { TaskWatcher } from '../common/task-watcher'; @@ -45,6 +48,11 @@ import { ProblemMatcherRegistry } from './task-problem-matcher-registry'; import { Range } from 'vscode-languageserver-types'; import URI from '@theia/core/lib/common/uri'; +export interface QuickPickProblemMatcherItem { + problemMatchers: NamedProblemMatcher[] | undefined; + learnMore?: boolean; +} + @injectable() export class TaskService implements TaskConfigurationClient { /** @@ -110,6 +118,12 @@ export class TaskService implements TaskConfigurationClient { @inject(ProblemMatcherRegistry) protected readonly problemMatcherRegistry: ProblemMatcherRegistry; + @inject(QuickPickService) + protected readonly quickPick: QuickPickService; + + @inject(OpenerService) + protected readonly openerService: OpenerService; + /** * @deprecated To be removed in 0.5.0 */ @@ -311,7 +325,7 @@ export class TaskService implements TaskConfigurationClient { if (!task) { this.logger.error(`Can't get task launch configuration for label: ${taskLabel}`); return; - } else if (task.problemMatcher) { + } else { Object.assign(customizationObject, { type: task.type, problemMatcher: task.problemMatcher @@ -323,36 +337,67 @@ export class TaskService implements TaskConfigurationClient { Object.assign(customizationObject, customizationFound); } } - await this.problemMatcherRegistry.onReady(); - const notResolvedMatchers = customizationObject.problemMatcher ? - (Array.isArray(customizationObject.problemMatcher) ? customizationObject.problemMatcher : [customizationObject.problemMatcher]) : []; - const resolvedMatchers: ProblemMatcher[] = []; - // resolve matchers before passing them to the server - for (const matcher of notResolvedMatchers) { - let resolvedMatcher: ProblemMatcher | undefined; - if (typeof matcher === 'string') { - resolvedMatcher = this.problemMatcherRegistry.get(matcher); - } else { - resolvedMatcher = await this.problemMatcherRegistry.getProblemMatcherFromContribution(matcher); + + if (!customizationObject.problemMatcher) { + // ask the user what s/he wants to use to parse the task output + const items = this.getCustomizeProblemMatcherItems(); + const selected = await this.quickPick.show(items, { + placeholder: 'Select for which kind of errors and warnings to scan the task output' + }); + if (selected) { + if (selected.problemMatchers) { + let matcherNames: string[] = []; + if (selected.problemMatchers && selected.problemMatchers.length === 0) { // never parse output for this task + matcherNames = []; + } else if (selected.problemMatchers && selected.problemMatchers.length > 0) { // continue with user-selected parser + matcherNames = selected.problemMatchers.map(matcher => matcher.name); + } + customizationObject.problemMatcher = matcherNames; + + // write the selected matcher (or the decision of "never parse") into the `tasks.json` + this.taskConfigurations.saveProblemMatcherForTask(task, matcherNames); + } else if (selected.learnMore) { // user wants to learn more about parsing task output + open(this.openerService, new URI('https://code.visualstudio.com/docs/editor/tasks#_processing-task-output-with-problem-matchers')); + } + // else, continue the task with no parser + } else { // do not start the task in case that the user did not select any item from the list + return; } - if (resolvedMatcher) { - const scope = task._scope || task._source; - if (resolvedMatcher.filePrefix && scope) { - const options = { context: new URI(scope).withScheme('file') }; - const resolvedPrefix = await this.variableResolverService.resolve(resolvedMatcher.filePrefix, options); - Object.assign(resolvedMatcher, { filePrefix: resolvedPrefix }); + } + + const notResolvedMatchers = customizationObject.problemMatcher ? + (Array.isArray(customizationObject.problemMatcher) ? customizationObject.problemMatcher : [customizationObject.problemMatcher]) : undefined; + let resolvedMatchers: ProblemMatcher[] | undefined = []; + if (notResolvedMatchers) { + // resolve matchers before passing them to the server + for (const matcher of notResolvedMatchers) { + let resolvedMatcher: ProblemMatcher | undefined; + await this.problemMatcherRegistry.onReady(); + if (typeof matcher === 'string') { + resolvedMatcher = this.problemMatcherRegistry.get(matcher); + } else { + resolvedMatcher = await this.problemMatcherRegistry.getProblemMatcherFromContribution(matcher); + } + if (resolvedMatcher) { + const scope = task._scope || task._source; + if (resolvedMatcher.filePrefix && scope) { + const options = { context: new URI(scope).withScheme('file') }; + const resolvedPrefix = await this.variableResolverService.resolve(resolvedMatcher.filePrefix, options); + Object.assign(resolvedMatcher, { filePrefix: resolvedPrefix }); + } + resolvedMatchers.push(resolvedMatcher); } - resolvedMatchers.push(resolvedMatcher); } + } else { + resolvedMatchers = undefined; } + return this.runTask(task, { customization: { ...customizationObject, ...{ problemMatcher: resolvedMatchers } } }); } async runTask(task: TaskConfiguration, option?: RunTaskOption): Promise { - const source = task._source; - const taskLabel = task.label; if (option && option.customization) { const taskDefinition = this.taskDefinitionRegistry.getDefinition(task); if (taskDefinition) { // use the customization object to override the task config @@ -365,43 +410,12 @@ export class TaskService implements TaskConfigurationClient { } } - const resolver = await this.taskResolverRegistry.getResolver(task.type); - let resolvedTask: TaskConfiguration; - try { - resolvedTask = resolver ? await resolver.resolveTask(task) : task; - this.addRecentTasks(task); - } catch (error) { - this.logger.error(`Error resolving task '${taskLabel}': ${error}`); - this.messageService.error(`Error resolving task '${taskLabel}': ${error}`); - return; + const resolvedTask = await this.getResolvedTask(task); + if (resolvedTask) { + // remove problem markers from the same source before running the task + await this.removeProblemMarks(option); + return this.runResolvedTask(resolvedTask, option); } - - await this.removeProblemMarks(option); - - let taskInfo: TaskInfo; - try { - taskInfo = await this.taskServer.run(resolvedTask, this.getContext(), option); - this.lastTask = { source, taskLabel }; - } catch (error) { - const errorStr = `Error launching task '${taskLabel}': ${error.message}`; - this.logger.error(errorStr); - this.messageService.error(errorStr); - return; - } - - this.logger.debug(`Task created. Task id: ${taskInfo.taskId}`); - - /** - * open terminal widget if the task is based on a terminal process (type: 'shell' or 'process') - * - * @todo Use a different mechanism to determine if the task should be attached? - * Reason: Maybe a new task type wants to also be displayed in a terminal. - */ - if (typeof taskInfo.terminalId === 'number') { - this.attach(taskInfo.terminalId, taskInfo.taskId); - } - - return taskInfo; } async runTaskByLabel(taskLabel: string): Promise { @@ -429,6 +443,76 @@ export class TaskService implements TaskConfigurationClient { } } + private async getResolvedTask(task: TaskConfiguration): Promise { + const resolver = await this.taskResolverRegistry.getResolver(task.type); + try { + const resolvedTask = resolver ? await resolver.resolveTask(task) : task; + this.addRecentTasks(task); + return resolvedTask; + } catch (error) { + const errMessage = `Error resolving task '${task.label}': ${error}`; + this.logger.error(errMessage); + this.messageService.error(errMessage); + } + } + + /** + * Runs the resolved task and opens terminal widget if the task is based on a terminal process + * @param resolvedTask the resolved task + * @param option options to run the resolved task + */ + private async runResolvedTask(resolvedTask: TaskConfiguration, option?: RunTaskOption): Promise { + const source = resolvedTask._source; + const taskLabel = resolvedTask.label; + try { + const taskInfo = await this.taskServer.run(resolvedTask, this.getContext(), option); + this.lastTask = { source, taskLabel }; + this.logger.debug(`Task created. Task id: ${taskInfo.taskId}`); + + /** + * open terminal widget if the task is based on a terminal process (type: 'shell' or 'process') + * + * @todo Use a different mechanism to determine if the task should be attached? + * Reason: Maybe a new task type wants to also be displayed in a terminal. + */ + if (typeof taskInfo.terminalId === 'number') { + this.attach(taskInfo.terminalId, taskInfo.taskId); + } + return taskInfo; + } catch (error) { + const errorStr = `Error launching task '${taskLabel}': ${error.message}`; + this.logger.error(errorStr); + this.messageService.error(errorStr); + } + } + + private getCustomizeProblemMatcherItems(): QuickPickItem[] { + const items: QuickPickItem[] = []; + items.push({ + label: 'Continue without scanning the task output', + value: { problemMatchers: undefined } + }); + items.push({ + label: 'Never scan the task output', + value: { problemMatchers: [] } + }); + items.push({ + label: 'Learn more about scanning the task output', + value: { problemMatchers: undefined, learnMore: true } + }); + items.push({ type: 'separator', label: 'registered parsers' }); + + const registeredProblemMatchers = this.problemMatcherRegistry.getAll(); + items.push(...registeredProblemMatchers.map(matcher => + ({ + label: matcher.label, + value: { problemMatchers: [matcher] }, + description: matcher.name.startsWith('$') ? matcher.name : `$${matcher.name}` + }) + )); + return items; + } + /** * Run selected text in the last active terminal. */