diff --git a/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts b/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts index 2da1e20df..2ad150784 100644 --- a/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts +++ b/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts @@ -29,6 +29,7 @@ export class BurnBootloader extends CoreServiceContribution { } private async burnBootloader(): Promise<void> { + this.clearVisibleNotification(); const options = await this.options(); try { await this.doWithProgress({ diff --git a/arduino-ide-extension/src/browser/contributions/compiler-errors.ts b/arduino-ide-extension/src/browser/contributions/compiler-errors.ts index b3946bfd2..58e8dcff1 100644 --- a/arduino-ide-extension/src/browser/contributions/compiler-errors.ts +++ b/arduino-ide-extension/src/browser/contributions/compiler-errors.ts @@ -4,11 +4,13 @@ import { Disposable, DisposableCollection, Emitter, + MaybeArray, MaybePromise, nls, notEmpty, } from '@theia/core'; import { ApplicationShell, FrontendApplication } from '@theia/core/lib/browser'; +import { ITextModel } from '@theia/monaco-editor-core/esm/vs/editor/common/model'; import URI from '@theia/core/lib/common/uri'; import { inject, injectable } from '@theia/core/shared/inversify'; import { @@ -28,14 +30,15 @@ import * as monaco from '@theia/monaco-editor-core'; import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; import { MonacoToProtocolConverter } from '@theia/monaco/lib/browser/monaco-to-protocol-converter'; import { ProtocolToMonacoConverter } from '@theia/monaco/lib/browser/protocol-to-monaco-converter'; +import { OutputUri } from '@theia/output/lib/common/output-uri'; import { CoreError } from '../../common/protocol/core-service'; import { ErrorRevealStrategy } from '../arduino-preferences'; -import { InoSelector } from '../ino-selectors'; -import { fullRange } from '../utils/monaco'; +import { ArduinoOutputSelector, InoSelector } from '../selectors'; import { Contribution } from './contribution'; import { CoreErrorHandler } from './core-error-handler'; +import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model'; -interface ErrorDecoration { +interface ErrorDecorationRef { /** * This is the unique ID of the decoration given by `monaco`. */ @@ -45,72 +48,89 @@ interface ErrorDecoration { */ readonly uri: string; } +export namespace ErrorDecorationRef { + export function is(arg: unknown): arg is ErrorDecorationRef { + if (typeof arg === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const object = arg as any; + return ( + 'uri' in object && + typeof object['uri'] === 'string' && + 'id' in object && + typeof object['id'] === 'string' + ); + } + return false; + } + export function sameAs( + left: ErrorDecorationRef, + right: ErrorDecorationRef + ): boolean { + return left.id === right.id && left.uri === right.uri; + } +} + +interface ErrorDecoration extends ErrorDecorationRef { + /** + * The range of the error location the error in the compiler output from the CLI. + */ + readonly rangesInOutput: monaco.Range[]; +} namespace ErrorDecoration { export function rangeOf( - { id, uri }: ErrorDecoration, - editorProvider: (uri: string) => Promise<MonacoEditor | undefined> - ): Promise<monaco.Range | undefined>; - export function rangeOf( - { id, uri }: ErrorDecoration, - editorProvider: MonacoEditor + editorOrModel: MonacoEditor | ITextModel | undefined, + decorations: ErrorDecoration ): monaco.Range | undefined; export function rangeOf( - { id, uri }: ErrorDecoration, - editorProvider: - | ((uri: string) => Promise<MonacoEditor | undefined>) - | MonacoEditor - ): MaybePromise<monaco.Range | undefined> { - if (editorProvider instanceof MonacoEditor) { - const control = editorProvider.getControl(); - const model = control.getModel(); - if (model) { - return control - .getDecorationsInRange(fullRange(model)) - ?.find(({ id: candidateId }) => id === candidateId)?.range; + editorOrModel: MonacoEditor | ITextModel | undefined, + decorations: ErrorDecoration[] + ): (monaco.Range | undefined)[]; + export function rangeOf( + editorOrModel: MonacoEditor | ITextModel | undefined, + decorations: ErrorDecoration | ErrorDecoration[] + ): MaybePromise<MaybeArray<monaco.Range | undefined>> { + if (editorOrModel) { + const allDecorations = getAllDecorations(editorOrModel); + if (allDecorations) { + if (Array.isArray(decorations)) { + return decorations.map(({ id: decorationId }) => + findRangeOf(decorationId, allDecorations) + ); + } else { + return findRangeOf(decorations.id, allDecorations); + } } - return undefined; } - return editorProvider(uri).then((editor) => { - if (editor) { - return rangeOf({ id, uri }, editor); - } - return undefined; - }); + return Array.isArray(decorations) + ? decorations.map(() => undefined) + : undefined; } - - // export async function rangeOf( - // { id, uri }: ErrorDecoration, - // editorProvider: - // | ((uri: string) => Promise<MonacoEditor | undefined>) - // | MonacoEditor - // ): Promise<monaco.Range | undefined> { - // const editor = - // editorProvider instanceof MonacoEditor - // ? editorProvider - // : await editorProvider(uri); - // if (editor) { - // const control = editor.getControl(); - // const model = control.getModel(); - // if (model) { - // return control - // .getDecorationsInRange(fullRange(model)) - // ?.find(({ id: candidateId }) => id === candidateId)?.range; - // } - // } - // return undefined; - // } - export function sameAs( - left: ErrorDecoration, - right: ErrorDecoration - ): boolean { - return left.id === right.id && left.uri === right.uri; + function findRangeOf( + decorationId: string, + allDecorations: { id: string; range?: monaco.Range }[] + ): monaco.Range | undefined { + return allDecorations.find( + ({ id: candidateId }) => candidateId === decorationId + )?.range; + } + function getAllDecorations( + editorOrModel: MonacoEditor | ITextModel + ): { id: string; range?: monaco.Range }[] { + if (editorOrModel instanceof MonacoEditor) { + const model = editorOrModel.getControl().getModel(); + if (!model) { + return []; + } + return model.getAllDecorations(); + } + return editorOrModel.getAllDecorations(); } } @injectable() export class CompilerErrors extends Contribution - implements monaco.languages.CodeLensProvider + implements monaco.languages.CodeLensProvider, monaco.languages.LinkProvider { @inject(EditorManager) private readonly editorManager: EditorManager; @@ -119,11 +139,14 @@ export class CompilerErrors private readonly p2m: ProtocolToMonacoConverter; @inject(MonacoToProtocolConverter) - private readonly mp2: MonacoToProtocolConverter; + private readonly m2p: MonacoToProtocolConverter; @inject(CoreErrorHandler) private readonly coreErrorHandler: CoreErrorHandler; + private revealStrategy = ErrorRevealStrategy.Default; + private experimental = false; + private readonly errors: ErrorDecoration[] = []; private readonly onDidChangeEmitter = new monaco.Emitter<this>(); private readonly currentErrorDidChangEmitter = new Emitter<ErrorDecoration>(); @@ -131,8 +154,8 @@ export class CompilerErrors this.currentErrorDidChangEmitter.event; private readonly toDisposeOnCompilerErrorDidChange = new DisposableCollection(); + private shell: ApplicationShell | undefined; - private revealStrategy = ErrorRevealStrategy.Default; private currentError: ErrorDecoration | undefined; private get currentErrorIndex(): number { const current = this.currentError; @@ -140,46 +163,75 @@ export class CompilerErrors return -1; } return this.errors.findIndex((error) => - ErrorDecoration.sameAs(error, current) + ErrorDecorationRef.sameAs(error, current) ); } override onStart(app: FrontendApplication): void { this.shell = app.shell; monaco.languages.registerCodeLensProvider(InoSelector, this); + monaco.languages.registerLinkProvider(ArduinoOutputSelector, this); this.coreErrorHandler.onCompilerErrorsDidChange((errors) => - this.filter(errors).then(this.handleCompilerErrorsDidChange.bind(this)) + this.handleCompilerErrorsDidChange(errors) ); this.onCurrentErrorDidChange(async (error) => { - const range = await ErrorDecoration.rangeOf(error, (uri) => - this.monacoEditor(uri) - ); - if (!range) { + const monacoEditor = await this.monacoEditor(error.uri); + const monacoRange = ErrorDecoration.rangeOf(monacoEditor, error); + if (!monacoRange) { console.warn( 'compiler-errors', `Could not find range of decoration: ${error.id}` ); return; } + const range = this.m2p.asRange(monacoRange); const editor = await this.revealLocationInEditor({ uri: error.uri, - range: this.mp2.asRange(range), + range, }); if (!editor) { console.warn( 'compiler-errors', `Failed to mark error ${error.id} as the current one.` ); + } else { + const monacoEditor = this.monacoEditor(editor); + if (monacoEditor) { + monacoEditor.cursor = range.start; + } } }); + } + + override onReady(): MaybePromise<void> { this.preferences.ready.then(() => { - this.preferences.onPreferenceChanged(({ preferenceName, newValue }) => { - if (preferenceName === 'arduino.compile.revealRange') { - this.revealStrategy = ErrorRevealStrategy.is(newValue) - ? newValue - : ErrorRevealStrategy.Default; + this.experimental = Boolean( + this.preferences['arduino.compile.experimental'] + ); + const strategy = this.preferences['arduino.compile.revealRange']; + this.revealStrategy = ErrorRevealStrategy.is(strategy) + ? strategy + : ErrorRevealStrategy.Default; + this.preferences.onPreferenceChanged( + ({ preferenceName, newValue, oldValue }) => { + if (newValue === oldValue) { + return; + } + switch (preferenceName) { + case 'arduino.compile.revealRange': { + this.revealStrategy = ErrorRevealStrategy.is(newValue) + ? newValue + : ErrorRevealStrategy.Default; + return; + } + case 'arduino.compile.experimental': { + this.experimental = Boolean(newValue); + this.onDidChangeEmitter.fire(this); + return; + } + } } - }); + ); }); } @@ -196,9 +248,13 @@ export class CompilerErrors } const nextError = this.errors[index === this.errors.length - 1 ? 0 : index + 1]; - this.markAsCurrentError(nextError); + return this.markAsCurrentError(nextError, { + forceReselect: true, + reveal: true, + }); }, - isEnabled: () => !!this.currentError && this.errors.length > 1, + isEnabled: () => + this.experimental && !!this.currentError && this.errors.length > 1, }); registry.registerCommand(CompilerErrors.Commands.PREVIOUS_ERROR, { execute: () => { @@ -212,9 +268,24 @@ export class CompilerErrors } const previousError = this.errors[index === 0 ? this.errors.length - 1 : index - 1]; - this.markAsCurrentError(previousError); + return this.markAsCurrentError(previousError, { + forceReselect: true, + reveal: true, + }); }, - isEnabled: () => !!this.currentError && this.errors.length > 1, + isEnabled: () => + this.experimental && !!this.currentError && this.errors.length > 1, + }); + registry.registerCommand(CompilerErrors.Commands.MARK_AS_CURRENT, { + execute: (arg: unknown) => { + if (ErrorDecorationRef.is(arg)) { + return this.markAsCurrentError( + { id: arg.id, uri: new URI(arg.uri).toString() }, // Make sure the URI fragments are encoded. On Windows, `C:` is encoded as `C%3A`. + { forceReselect: true, reveal: true } + ); + } + }, + isEnabled: () => !!this.errors.length, }); } @@ -229,13 +300,13 @@ export class CompilerErrors ): Promise<monaco.languages.CodeLensList> { const lenses: monaco.languages.CodeLens[] = []; if ( + this.experimental && this.currentError && this.currentError.uri === model.uri.toString() && this.errors.length > 1 ) { - const range = await ErrorDecoration.rangeOf(this.currentError, (uri) => - this.monacoEditor(uri) - ); + const monacoEditor = await this.monacoEditor(model.uri); + const range = ErrorDecoration.rangeOf(monacoEditor, this.currentError); if (range) { lenses.push( { @@ -268,14 +339,81 @@ export class CompilerErrors }; } + async provideLinks( + model: monaco.editor.ITextModel, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _token: monaco.CancellationToken + ): Promise<monaco.languages.ILinksList> { + const links: monaco.languages.ILink[] = []; + if ( + model.uri.scheme === OutputUri.SCHEME && + model.uri.path === '/Arduino' + ) { + links.push( + ...this.errors + .filter((decoration) => !!decoration.rangesInOutput.length) + .map(({ rangesInOutput, id, uri }) => + rangesInOutput.map( + (range) => + <monaco.languages.ILink>{ + range, + url: monaco.Uri.parse(`command://`).with({ + query: JSON.stringify({ id, uri }), + path: CompilerErrors.Commands.MARK_AS_CURRENT.id, + }), + tooltip: nls.localize( + 'arduino/editor/revealError', + 'Reveal Error' + ), + } + ) + ) + .reduce((acc, curr) => acc.concat(curr), []) + ); + } else { + console.warn('unexpected URI: ' + model.uri.toString()); + } + return { links }; + } + + async resolveLink( + link: monaco.languages.ILink, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _token: monaco.CancellationToken + ): Promise<monaco.languages.ILink | undefined> { + if (!this.experimental) { + return undefined; + } + const { url } = link; + if (url) { + const candidateUri = new URI( + typeof url === 'string' ? url : url.toString() + ); + const candidateId = candidateUri.path.toString(); + const error = this.errors.find((error) => error.id === candidateId); + if (error) { + const monacoEditor = await this.monacoEditor(error.uri); + const range = ErrorDecoration.rangeOf(monacoEditor, error); + if (range) { + return { + range, + url: monaco.Uri.parse(error.uri), + }; + } + } + } + return undefined; + } + private async handleCompilerErrorsDidChange( errors: CoreError.ErrorLocation[] ): Promise<void> { this.toDisposeOnCompilerErrorDidChange.dispose(); - const compilerErrorsPerResource = this.groupByResource( - await this.filter(errors) + const groupedErrors = this.groupBy( + errors, + (error: CoreError.ErrorLocation) => error.location.uri ); - const decorations = await this.decorateEditors(compilerErrorsPerResource); + const decorations = await this.decorateEditors(groupedErrors); this.errors.push(...decorations.errors); this.toDisposeOnCompilerErrorDidChange.pushAll([ Disposable.create(() => (this.errors.length = 0)), @@ -283,17 +421,17 @@ export class CompilerErrors ...(await Promise.all([ decorations.dispose, this.trackEditors( - compilerErrorsPerResource, + groupedErrors, (editor) => - editor.editor.onSelectionChanged((selection) => + editor.onSelectionChanged((selection) => this.handleSelectionChange(editor, selection) ), (editor) => - editor.onDidDispose(() => - this.handleEditorDidDispose(editor.editor.uri.toString()) + editor.onDispose(() => + this.handleEditorDidDispose(editor.uri.toString()) ), (editor) => - editor.editor.onDocumentContentChanged((event) => + editor.onDocumentContentChanged((event) => this.handleDocumentContentChange(editor, event) ) ), @@ -301,22 +439,11 @@ export class CompilerErrors ]); const currentError = this.errors[0]; if (currentError) { - await this.markAsCurrentError(currentError); - } - } - - private async filter( - errors: CoreError.ErrorLocation[] - ): Promise<CoreError.ErrorLocation[]> { - if (!errors.length) { - return []; - } - await this.preferences.ready; - if (this.preferences['arduino.compile.experimental']) { - return errors; + await this.markAsCurrentError(currentError, { + forceReselect: true, + reveal: true, + }); } - // Always shows maximum one error; hence the code lens navigation is unavailable. - return [errors[0]]; } private async decorateEditors( @@ -342,11 +469,11 @@ export class CompilerErrors uri: string, errors: CoreError.ErrorLocation[] ): Promise<{ dispose: Disposable; errors: ErrorDecoration[] }> { - const editor = await this.editorManager.getByUri(new URI(uri)); + const editor = await this.monacoEditor(uri); if (!editor) { return { dispose: Disposable.NULL, errors: [] }; } - const oldDecorations = editor.editor.deltaDecorations({ + const oldDecorations = editor.deltaDecorations({ oldDecorations: [], newDecorations: errors.map((error) => this.compilerErrorDecoration(error.location.range) @@ -355,13 +482,19 @@ export class CompilerErrors return { dispose: Disposable.create(() => { if (editor) { - editor.editor.deltaDecorations({ + editor.deltaDecorations({ oldDecorations, newDecorations: [], }); } }), - errors: oldDecorations.map((id) => ({ id, uri })), + errors: oldDecorations.map((id, index) => ({ + id, + uri, + rangesInOutput: errors[index].rangesInOutput.map((range) => + this.p2m.asRange(range) + ), + })), }; } @@ -371,7 +504,7 @@ export class CompilerErrors options: { isWholeLine: true, className: 'compiler-error', - stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges, + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, }, }; } @@ -379,11 +512,10 @@ export class CompilerErrors /** * Tracks the selection in all editors that have an error. If the editor selection overlaps one of the compiler error's range, mark as current error. */ - private handleSelectionChange(editor: EditorWidget, selection: Range): void { - const monacoEditor = this.monacoEditor(editor); - if (!monacoEditor) { - return; - } + private handleSelectionChange( + monacoEditor: MonacoEditor, + selection: Range + ): void { const uri = monacoEditor.uri.toString(); const monacoSelection = this.p2m.asRange(selection); console.log( @@ -418,12 +550,13 @@ export class CompilerErrors console.trace('No match'); return undefined; }; - const error = this.errors - .filter((error) => error.uri === uri) - .map((error) => ({ - error, - range: ErrorDecoration.rangeOf(error, monacoEditor), - })) + const errorsPerResource = this.errors.filter((error) => error.uri === uri); + const rangesPerResource = ErrorDecoration.rangeOf( + monacoEditor, + errorsPerResource + ); + const error = rangesPerResource + .map((range, index) => ({ error: errorsPerResource[index], range })) .map(({ error, range }) => { if (range) { const priority = calculatePriority(range, monacoSelection); @@ -464,66 +597,77 @@ export class CompilerErrors } /** - * If a document change "destroys" the range of the decoration, the decoration must be removed. + * If the text document changes in the line where compiler errors are, the compiler errors will be removed. */ private handleDocumentContentChange( - editor: EditorWidget, + monacoEditor: MonacoEditor, event: TextDocumentChangeEvent ): void { - const monacoEditor = this.monacoEditor(editor); - if (!monacoEditor) { - return; - } - // A decoration location can be "destroyed", hence should be deleted when: - // - deleting range (start != end AND text is empty) - // - inserting text into range (start != end AND text is not empty) - // Filter unrelated delta changes to spare the CPU. - const relevantChanges = event.contentChanges.filter( - ({ range: { start, end } }) => - start.line !== end.line || start.character !== end.character + const errorsPerResource = this.errors.filter( + (error) => error.uri === event.document.uri ); - if (!relevantChanges.length) { - return; + let editorOrModel: MonacoEditor | ITextModel = monacoEditor; + const doc = event.document; + if (doc instanceof MonacoEditorModel) { + editorOrModel = doc.textEditorModel; } - - const resolvedMarkers = this.errors - .filter((error) => error.uri === event.document.uri) - .map((error, index) => { - const range = ErrorDecoration.rangeOf(error, monacoEditor); - if (range) { - return { error, range, index }; - } - return undefined; - }) - .filter(notEmpty); - - const decorationIdsToRemove = relevantChanges + const rangesPerResource = ErrorDecoration.rangeOf( + editorOrModel, + errorsPerResource + ); + const resolvedDecorations = rangesPerResource.map((range, index) => ({ + error: errorsPerResource[index], + range, + })); + const decoratorsToRemove = event.contentChanges .map(({ range }) => this.p2m.asRange(range)) - .map((changeRange) => - resolvedMarkers.filter(({ range: decorationRange }) => - changeRange.containsRange(decorationRange) - ) + .map((changedRange) => + resolvedDecorations + .filter(({ range: decorationRange }) => { + if (!decorationRange) { + return false; + } + const affects = + changedRange.startLineNumber <= decorationRange.startLineNumber && + changedRange.endLineNumber >= decorationRange.endLineNumber; + console.log( + 'compiler-errors', + `decoration range: ${decorationRange.toString()}, change range: ${changedRange.toString()}, affects: ${affects}` + ); + return affects; + }) + .map(({ error }) => { + const index = this.errors.findIndex((candidate) => + ErrorDecorationRef.sameAs(candidate, error) + ); + return index !== -1 ? { error, index } : undefined; + }) + .filter(notEmpty) ) .reduce((acc, curr) => acc.concat(curr), []) - .map(({ error, index }) => { - this.errors.splice(index, 1); - return error.id; - }); - if (!decorationIdsToRemove.length) { - return; + .sort((left, right) => left.index - right.index); // highest index last + + if (decoratorsToRemove.length) { + let i = decoratorsToRemove.length; + while (i--) { + this.errors.splice(decoratorsToRemove[i].index, 1); + } + monacoEditor.getControl().deltaDecorations( + decoratorsToRemove.map(({ error }) => error.id), + [] + ); + this.onDidChangeEmitter.fire(this); } - monacoEditor.getControl().deltaDecorations(decorationIdsToRemove, []); - this.onDidChangeEmitter.fire(this); } private async trackEditors( errors: Map<string, CoreError.ErrorLocation[]>, - ...track: ((editor: EditorWidget) => Disposable)[] + ...track: ((editor: MonacoEditor) => Disposable)[] ): Promise<Disposable> { return new DisposableCollection( ...(await Promise.all( Array.from(errors.keys()).map(async (uri) => { - const editor = await this.editorManager.getByUri(new URI(uri)); + const editor = await this.monacoEditor(uri); if (!editor) { return Disposable.NULL; } @@ -533,15 +677,18 @@ export class CompilerErrors ); } - private async markAsCurrentError(error: ErrorDecoration): Promise<void> { + private async markAsCurrentError( + ref: ErrorDecorationRef, + options?: { forceReselect?: boolean; reveal?: boolean } + ): Promise<void> { const index = this.errors.findIndex((candidate) => - ErrorDecoration.sameAs(candidate, error) + ErrorDecorationRef.sameAs(candidate, ref) ); if (index < 0) { console.warn( 'compiler-errors', `Failed to mark error ${ - error.id + ref.id } as the current one. Error is unknown. Known errors are: ${this.errors.map( ({ id }) => id )}` @@ -550,15 +697,18 @@ export class CompilerErrors } const newError = this.errors[index]; if ( + options?.forceReselect || !this.currentError || - !ErrorDecoration.sameAs(this.currentError, newError) + !ErrorDecorationRef.sameAs(this.currentError, newError) ) { this.currentError = this.errors[index]; console.log( 'compiler-errors', `Current error changed to ${this.currentError.id}` ); - this.currentErrorDidChangEmitter.fire(this.currentError); + if (options?.reveal) { + this.currentErrorDidChangEmitter.fire(this.currentError); + } this.onDidChangeEmitter.fire(this); } } @@ -598,27 +748,28 @@ export class CompilerErrors return undefined; } - private groupByResource( - errors: CoreError.ErrorLocation[] - ): Map<string, CoreError.ErrorLocation[]> { - return errors.reduce((acc, curr) => { - const { - location: { uri }, - } = curr; - let errors = acc.get(uri); - if (!errors) { - errors = []; - acc.set(uri, errors); + private groupBy<K, V>( + elements: V[], + extractKey: (element: V) => K + ): Map<K, V[]> { + return elements.reduce((acc, curr) => { + const key = extractKey(curr); + let values = acc.get(key); + if (!values) { + values = []; + acc.set(key, values); } - errors.push(curr); + values.push(curr); return acc; - }, new Map<string, CoreError.ErrorLocation[]>()); + }, new Map<K, V[]>()); } private monacoEditor(widget: EditorWidget): MonacoEditor | undefined; - private monacoEditor(uri: string): Promise<MonacoEditor | undefined>; private monacoEditor( - uriOrWidget: string | EditorWidget + uri: string | monaco.Uri + ): Promise<MonacoEditor | undefined>; + private monacoEditor( + uriOrWidget: string | monaco.Uri | EditorWidget ): MaybePromise<MonacoEditor | undefined> { if (uriOrWidget instanceof EditorWidget) { const editor = uriOrWidget.editor; @@ -646,5 +797,8 @@ export namespace CompilerErrors { export const PREVIOUS_ERROR: Command = { id: 'arduino-editor-previous-error', }; + export const MARK_AS_CURRENT: Command = { + id: 'arduino-editor-mark-as-current-error', + }; } } diff --git a/arduino-ide-extension/src/browser/contributions/contribution.ts b/arduino-ide-extension/src/browser/contributions/contribution.ts index d3ae285f7..309a72dbc 100644 --- a/arduino-ide-extension/src/browser/contributions/contribution.ts +++ b/arduino-ide-extension/src/browser/contributions/contribution.ts @@ -59,6 +59,8 @@ import { ClipboardService } from '@theia/core/lib/browser/clipboard-service'; import { ExecuteWithProgress } from '../../common/protocol/progressible'; import { BoardsServiceProvider } from '../boards/boards-service-provider'; import { BoardsDataStore } from '../boards/boards-data-store'; +import { NotificationManager } from '../theia/messages/notifications-manager'; +import { MessageType } from '@theia/core/lib/common/message-service-protocol'; export { Command, @@ -186,6 +188,22 @@ export abstract class CoreServiceContribution extends SketchContribution { @inject(ResponseServiceClient) private readonly responseService: ResponseServiceClient; + @inject(NotificationManager) + private readonly notificationManager: NotificationManager; + + /** + * This is the internal (Theia) ID of the notification that is currently visible. + * It's stored here as a field to be able to close it before executing any new core command (such as verify, upload, etc.) + */ + private visibleNotificationId: string | undefined; + + protected clearVisibleNotification(): void { + if (this.visibleNotificationId) { + this.notificationManager.clear(this.visibleNotificationId); + this.visibleNotificationId = undefined; + } + } + protected handleError(error: unknown): void { this.tryToastErrorMessage(error); } @@ -208,6 +226,7 @@ export abstract class CoreServiceContribution extends SketchContribution { 'arduino/coreContribution/copyError', 'Copy error messages' ); + this.visibleNotificationId = this.notificationId(message, copyAction); this.messageService.error(message, copyAction).then(async (action) => { if (action === copyAction) { const content = await this.outputChannelManager.contentOfChannel( @@ -241,6 +260,14 @@ export abstract class CoreServiceContribution extends SketchContribution { }); return result; } + + private notificationId(message: string, ...actions: string[]): string { + return this.notificationManager.getMessageId({ + text: message, + actions, + type: MessageType.Error, + }); + } } export namespace Contribution { diff --git a/arduino-ide-extension/src/browser/contributions/format.ts b/arduino-ide-extension/src/browser/contributions/format.ts index e270181b0..c92741f1f 100644 --- a/arduino-ide-extension/src/browser/contributions/format.ts +++ b/arduino-ide-extension/src/browser/contributions/format.ts @@ -2,8 +2,7 @@ import { MaybePromise } from '@theia/core'; import { inject, injectable } from '@theia/core/shared/inversify'; import * as monaco from '@theia/monaco-editor-core'; import { Formatter } from '../../common/protocol/formatter'; -import { InoSelector } from '../ino-selectors'; -import { fullRange } from '../utils/monaco'; +import { InoSelector } from '../selectors'; import { Contribution, URI } from './contribution'; @injectable() @@ -40,7 +39,7 @@ export class Format // eslint-disable-next-line @typescript-eslint/no-unused-vars _token: monaco.CancellationToken ): Promise<monaco.languages.TextEdit[]> { - const range = fullRange(model); + const range = model.getFullModelRange(); const text = await this.format(model, range, options); return [{ range, text }]; } diff --git a/arduino-ide-extension/src/browser/contributions/upload-sketch.ts b/arduino-ide-extension/src/browser/contributions/upload-sketch.ts index e15e612ad..d4bad139f 100644 --- a/arduino-ide-extension/src/browser/contributions/upload-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/upload-sketch.ts @@ -191,6 +191,7 @@ export class UploadSketch extends CoreServiceContribution { // uploadInProgress will be set to false whether the upload fails or not this.uploadInProgress = true; this.onDidChangeEmitter.fire(); + this.clearVisibleNotification(); const verifyOptions = await this.commandService.executeCommand<CoreService.Options.Compile>( diff --git a/arduino-ide-extension/src/browser/contributions/verify-sketch.ts b/arduino-ide-extension/src/browser/contributions/verify-sketch.ts index 3fc2d53a0..fe4de6f6a 100644 --- a/arduino-ide-extension/src/browser/contributions/verify-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/verify-sketch.ts @@ -108,6 +108,7 @@ export class VerifySketch extends CoreServiceContribution { this.verifyInProgress = true; this.onDidChangeEmitter.fire(); } + this.clearVisibleNotification(); this.coreErrorHandler.reset(); const options = await this.options(params?.exportBinaries); diff --git a/arduino-ide-extension/src/browser/ino-selectors.ts b/arduino-ide-extension/src/browser/selectors.ts similarity index 62% rename from arduino-ide-extension/src/browser/ino-selectors.ts rename to arduino-ide-extension/src/browser/selectors.ts index e413dba26..f1f898013 100644 --- a/arduino-ide-extension/src/browser/ino-selectors.ts +++ b/arduino-ide-extension/src/browser/selectors.ts @@ -1,4 +1,5 @@ import * as monaco from '@theia/monaco-editor-core'; +import { OutputUri } from '@theia/output/lib/common/output-uri'; /** * Exclusive "ino" document selector for monaco. */ @@ -11,3 +12,11 @@ function selectorOf( exclusive: true, // <-- this should make sure the custom formatter has higher precedence over the LS formatter. })); } + +/** + * Selector for the `monaco` resource in the Arduino _Output_ channel. + */ +export const ArduinoOutputSelector: monaco.languages.LanguageSelector = { + scheme: OutputUri.SCHEME, + pattern: '**/Arduino', +}; diff --git a/arduino-ide-extension/src/browser/theia/messages/notifications-manager.ts b/arduino-ide-extension/src/browser/theia/messages/notifications-manager.ts index c38427508..bdc1ed35f 100644 --- a/arduino-ide-extension/src/browser/theia/messages/notifications-manager.ts +++ b/arduino-ide-extension/src/browser/theia/messages/notifications-manager.ts @@ -1,9 +1,10 @@ -import { injectable } from '@theia/core/shared/inversify'; import { CancellationToken } from '@theia/core/lib/common/cancellation'; -import { +import type { + Message, ProgressMessage, ProgressUpdate, } from '@theia/core/lib/common/message-service-protocol'; +import { injectable } from '@theia/core/shared/inversify'; import { NotificationManager as TheiaNotificationManager } from '@theia/messages/lib/browser/notifications-manager'; @injectable() @@ -34,7 +35,9 @@ export class NotificationManager extends TheiaNotificationManager { this.fireUpdatedEvent(); } - protected override toPlainProgress(update: ProgressUpdate): number | undefined { + protected override toPlainProgress( + update: ProgressUpdate + ): number | undefined { if (!update.work) { return undefined; } @@ -43,4 +46,11 @@ export class NotificationManager extends TheiaNotificationManager { } return Math.min((update.work.done / update.work.total) * 100, 100); } + + /** + * For `public` visibility. + */ + override getMessageId(message: Message): string { + return super.getMessageId(message); + } } diff --git a/arduino-ide-extension/src/browser/utils/monaco.ts b/arduino-ide-extension/src/browser/utils/monaco.ts deleted file mode 100644 index 0e957a980..000000000 --- a/arduino-ide-extension/src/browser/utils/monaco.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as monaco from '@theia/monaco-editor-core'; - -export function fullRange(model: monaco.editor.ITextModel): monaco.Range { - const lastLine = model.getLineCount(); - const lastLineMaxColumn = model.getLineMaxColumn(lastLine); - const end = new monaco.Position(lastLine, lastLineMaxColumn); - return monaco.Range.fromPositions(new monaco.Position(1, 1), end); -} diff --git a/arduino-ide-extension/src/common/protocol/core-service.ts b/arduino-ide-extension/src/common/protocol/core-service.ts index 4c85ecc5c..a7124d865 100644 --- a/arduino-ide-extension/src/common/protocol/core-service.ts +++ b/arduino-ide-extension/src/common/protocol/core-service.ts @@ -1,5 +1,9 @@ import { ApplicationError } from '@theia/core/lib/common/application-error'; -import type { Location } from '@theia/core/shared/vscode-languageserver-protocol'; +import type { + Location, + Range, + Position, +} from '@theia/core/shared/vscode-languageserver-protocol'; import type { BoardUserField, Port, @@ -15,11 +19,41 @@ export const CompilerWarningLiterals = [ ] as const; export type CompilerWarnings = typeof CompilerWarningLiterals[number]; export namespace CoreError { - export interface ErrorLocation { + export interface ErrorLocationRef { readonly message: string; readonly location: Location; readonly details?: string; } + export namespace ErrorLocationRef { + export function equals( + left: ErrorLocationRef, + right: ErrorLocationRef + ): boolean { + return ( + left.message === right.message && + left.details === right.details && + equalsLocation(left.location, right.location) + ); + } + function equalsLocation(left: Location, right: Location): boolean { + return left.uri === right.uri && equalsRange(left.range, right.range); + } + function equalsRange(left: Range, right: Range): boolean { + return ( + equalsPosition(left.start, right.start) && + equalsPosition(left.end, right.end) + ); + } + function equalsPosition(left: Position, right: Position): boolean { + return left.character === right.character && left.line === right.line; + } + } + export interface ErrorLocation extends ErrorLocationRef { + /** + * The range of the error location source from the CLI output. + */ + readonly rangesInOutput: Range[]; // The same error might show up multiple times in the CLI output: https://github.com/arduino/arduino-cli/issues/1761 + } export const Codes = { Verify: 4001, Upload: 4002, diff --git a/arduino-ide-extension/src/node/cli-error-parser.ts b/arduino-ide-extension/src/node/cli-error-parser.ts index 0f72ea71c..d60ecb88e 100644 --- a/arduino-ide-extension/src/node/cli-error-parser.ts +++ b/arduino-ide-extension/src/node/cli-error-parser.ts @@ -5,25 +5,41 @@ import { Range, Position, } from '@theia/core/shared/vscode-languageserver-protocol'; -import type { CoreError } from '../common/protocol'; +import { CoreError } from '../common/protocol'; import { Sketch } from '../common/protocol/sketches-service'; -export interface ErrorSource { +export interface OutputSource { readonly content: string | ReadonlyArray<Uint8Array>; readonly sketch?: Sketch; } - -export function tryParseError(source: ErrorSource): CoreError.ErrorLocation[] { - const { content, sketch } = source; - const err = - typeof content === 'string' +export namespace OutputSource { + export function content(source: OutputSource): string { + const { content } = source; + return typeof content === 'string' ? content : Buffer.concat(content).toString('utf8'); + } +} + +export function tryParseError(source: OutputSource): CoreError.ErrorLocation[] { + const { sketch } = source; + const content = OutputSource.content(source); if (sketch) { - return tryParse(err) + return tryParse(content) .map(remapErrorMessages) .filter(isLocationInSketch(sketch)) - .map(toErrorInfo); + .map(toErrorInfo) + .reduce((acc, curr) => { + const existingRef = acc.find((candidate) => + CoreError.ErrorLocationRef.equals(candidate, curr) + ); + if (existingRef) { + existingRef.rangesInOutput.push(...curr.rangesInOutput); + } else { + acc.push(curr); + } + return acc; + }, [] as CoreError.ErrorLocation[]); } return []; } @@ -35,6 +51,7 @@ interface ParseResult { readonly errorPrefix: string; readonly error: string; readonly message?: string; + readonly rangeInOutput?: Range | undefined; } namespace ParseResult { export function keyOf(result: ParseResult): string { @@ -64,6 +81,7 @@ function toErrorInfo({ path, line, column, + rangeInOutput, }: ParseResult): CoreError.ErrorLocation { return { message: error, @@ -72,6 +90,7 @@ function toErrorInfo({ uri: FileUri.create(path).toString(), range: range(line, column), }, + rangesInOutput: rangeInOutput ? [rangeInOutput] : [], }; } @@ -86,48 +105,50 @@ function range(line: number, column?: number): Range { }; } -export function tryParse(raw: string): ParseResult[] { +function tryParse(content: string): ParseResult[] { // Shamelessly stolen from the Java IDE: https://github.com/arduino/Arduino/blob/43b0818f7fa8073301db1b80ac832b7b7596b828/arduino-core/src/cc/arduino/Compiler.java#L137 const re = new RegExp( '(.+\\.\\w+):(\\d+)(:\\d+)*:\\s*((fatal)?\\s*error:\\s*)(.*)\\s*', 'gm' ); - return [ - ...new Map( - Array.from(raw.matchAll(re) ?? []) - .map((match) => { - const [, path, rawLine, rawColumn, errorPrefix, , error] = match.map( - (match) => (match ? match.trim() : match) + return Array.from(content.matchAll(re) ?? []) + .map((match) => { + const { index: start } = match; + const [, path, rawLine, rawColumn, errorPrefix, , error] = match.map( + (match) => (match ? match.trim() : match) + ); + const line = Number.parseInt(rawLine, 10); + if (!Number.isInteger(line)) { + console.warn( + `Could not parse line number. Raw input: <${rawLine}>, parsed integer: <${line}>.` + ); + return undefined; + } + let column: number | undefined = undefined; + if (rawColumn) { + const normalizedRawColumn = rawColumn.slice(-1); // trims the leading colon => `:3` will be `3` + column = Number.parseInt(normalizedRawColumn, 10); + if (!Number.isInteger(column)) { + console.warn( + `Could not parse column number. Raw input: <${normalizedRawColumn}>, parsed integer: <${column}>.` ); - const line = Number.parseInt(rawLine, 10); - if (!Number.isInteger(line)) { - console.warn( - `Could not parse line number. Raw input: <${rawLine}>, parsed integer: <${line}>.` - ); - return undefined; - } - let column: number | undefined = undefined; - if (rawColumn) { - const normalizedRawColumn = rawColumn.slice(-1); // trims the leading colon => `:3` will be `3` - column = Number.parseInt(normalizedRawColumn, 10); - if (!Number.isInteger(column)) { - console.warn( - `Could not parse column number. Raw input: <${normalizedRawColumn}>, parsed integer: <${column}>.` - ); - } - } - return { - path, - line, - column, - errorPrefix, - error, - }; - }) - .filter(notEmpty) - .map((result) => [ParseResult.keyOf(result), result]) - ).values(), - ]; + } + } + const rangeInOutput = findRangeInOutput( + start, + { path, rawLine, rawColumn }, + content + ); + return { + path, + line, + column, + errorPrefix, + error, + rangeInOutput, + }; + }) + .filter(notEmpty); } /** @@ -161,3 +182,47 @@ const KnownErrors: Record<string, { error: string; message?: string }> = { ), }, }; + +function findRangeInOutput( + startIndex: number | undefined, + groups: { path: string; rawLine: string; rawColumn: string | null }, + content: string // TODO? lines: string[]? can this code break line on `\n`? const lines = content.split(/\r?\n/) ?? []; +): Range | undefined { + if (startIndex === undefined) { + return undefined; + } + // /path/to/location/Sketch/Sketch.ino:36:42 + const offset = + groups.path.length + + ':'.length + + groups.rawLine.length + + (groups.rawColumn ? groups.rawColumn.length : 0); + const start = toPosition(startIndex, content); + if (!start) { + return undefined; + } + const end = toPosition(startIndex + offset, content); + if (!end) { + return undefined; + } + return { start, end }; +} + +function toPosition(offset: number, content: string): Position | undefined { + let line = 0; + let character = 0; + const length = content.length; + for (let i = 0; i < length; i++) { + const c = content.charAt(i); + if (i === offset) { + return { line, character }; + } + if (c === '\n') { + line++; + character = 0; + } else { + character++; + } + } + return undefined; +} diff --git a/arduino-ide-extension/src/node/core-service-impl.ts b/arduino-ide-extension/src/node/core-service-impl.ts index a7dde4f2b..e556048ce 100644 --- a/arduino-ide-extension/src/node/core-service-impl.ts +++ b/arduino-ide-extension/src/node/core-service-impl.ts @@ -86,7 +86,7 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { reject(error); } else { const compilerErrors = tryParseError({ - content: handler.stderr, + content: handler.content, sketch: options.sketch, }); const message = nls.localize( @@ -224,7 +224,7 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { errorCtor( message, tryParseError({ - content: handler.stderr, + content: handler.content, sketch: options.sketch, }) ) @@ -291,7 +291,7 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { 'Error while burning the bootloader: {0}', error.details ), - tryParseError({ content: handler.stderr }) + tryParseError({ content: handler.content }) ) ); } @@ -342,19 +342,15 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { // TODO: why not creating a composite handler with progress, `build_path`, and out/err stream handlers? ...handlers: ((response: R) => void)[] ): Disposable & { - stderr: Buffer[]; + content: Buffer[]; onData: (response: R) => void; } { - const stderr: Buffer[] = []; + const content: Buffer[] = []; const buffer = new AutoFlushingBuffer((chunks) => { - Array.from(chunks.entries()).forEach(([severity, chunk]) => { - if (chunk) { - this.sendResponse(chunk, severity); - } - }); + chunks.forEach(([severity, chunk]) => this.sendResponse(chunk, severity)); }); const onData = StreamingResponse.createOnDataHandler({ - stderr, + content, onData: (out, err) => { buffer.addChunk(out); buffer.addChunk(err, OutputMessage.Severity.Error); @@ -363,7 +359,7 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { }); return { dispose: () => buffer.dispose(), - stderr, + content, onData, }; } @@ -432,14 +428,19 @@ namespace StreamingResponse { ): (response: R) => void { return (response: R) => { const out = response.getOutStream_asU8(); + if (out.length) { + options.content.push(out); + } const err = response.getErrStream_asU8(); - options.stderr.push(err); + if (err.length) { + options.content.push(err); + } options.onData(out, err); options.handlers?.forEach((handler) => handler(response)); }; } export interface Options<R extends StreamingResponse> { - readonly stderr: Uint8Array[]; + readonly content: Uint8Array[]; readonly onData: (out: Uint8Array, err: Uint8Array) => void; /** * Additional request handlers. diff --git a/arduino-ide-extension/src/node/utils/buffers.ts b/arduino-ide-extension/src/node/utils/buffers.ts index e703e1bce..63ed1a97c 100644 --- a/arduino-ide-extension/src/node/utils/buffers.ts +++ b/arduino-ide-extension/src/node/utils/buffers.ts @@ -3,13 +3,13 @@ import { Disposable } from '@theia/core/shared/vscode-languageserver-protocol'; import { OutputMessage } from '../../common/protocol'; export class AutoFlushingBuffer implements Disposable { - private readonly chunks = Chunks.create(); + private readonly chunks: Array<[OutputMessage.Severity, Uint8Array]> = []; private readonly toDispose; private timer?: NodeJS.Timeout; private disposed = false; constructor( - onFlush: (chunks: Map<OutputMessage.Severity, string | undefined>) => void, + onFlush: (chunks: Array<[OutputMessage.Severity, string]>) => void, taskTimeout: number = AutoFlushingBuffer.DEFAULT_FLUSH_TIMEOUT_MS ) { const task = () => { @@ -34,7 +34,9 @@ export class AutoFlushingBuffer implements Disposable { chunk: Uint8Array, severity: OutputMessage.Severity = OutputMessage.Severity.Info ): void { - this.chunks.get(severity)?.push(chunk); + if (chunk.length) { + this.chunks.push([severity, chunk]); + } } dispose(): void { @@ -49,19 +51,10 @@ export namespace AutoFlushingBuffer { export const DEFAULT_FLUSH_TIMEOUT_MS = 32; } -type Chunks = Map<OutputMessage.Severity, Uint8Array[]>; +type Chunks = Array<[OutputMessage.Severity, Uint8Array]>; namespace Chunks { - export function create(): Chunks { - return new Map([ - [OutputMessage.Severity.Error, []], - [OutputMessage.Severity.Warning, []], - [OutputMessage.Severity.Info, []], - ]); - } export function clear(chunks: Chunks): Chunks { - for (const chunk of chunks.values()) { - chunk.length = 0; - } + chunks.length = 0; return chunks; } export function isEmpty(chunks: Chunks): boolean { @@ -69,12 +62,35 @@ namespace Chunks { } export function toString( chunks: Chunks - ): Map<OutputMessage.Severity, string | undefined> { - return new Map( - Array.from(chunks.entries()).map(([severity, buffers]) => [ - severity, - buffers.length ? Buffer.concat(buffers).toString() : undefined, - ]) - ); + ): Array<[OutputMessage.Severity, string]> { + const result: Array<[OutputMessage.Severity, string]> = []; + let current: + | { severity: OutputMessage.Severity; buffers: Uint8Array[] } + | undefined = undefined; + const appendToResult = () => { + if (current && current.buffers) { + result.push([ + current.severity, + Buffer.concat(current.buffers).toString('utf-8'), + ]); + } + }; + for (const [severity, buffer] of chunks) { + if (!buffer.length) { + continue; + } + if (!current) { + current = { severity, buffers: [buffer] }; + } else { + if (current.severity === severity) { + current.buffers.push(buffer); + } else { + appendToResult(); + current = { severity, buffers: [buffer] }; + } + } + } + appendToResult(); + return result; } } diff --git a/i18n/en.json b/i18n/en.json index 88c997c90..cf0f73e6e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -155,7 +155,8 @@ "increaseFontSize": "Increase Font Size", "increaseIndent": "Increase Indent", "nextError": "Next Error", - "previousError": "Previous Error" + "previousError": "Previous Error", + "revealError": "Reveal Error" }, "electron": { "couldNotSave": "Could not save the sketch. Please copy your unsaved work into your favorite text editor, and restart the IDE.",