diff --git a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts index d52969253..1d99bf5ea 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -296,6 +296,8 @@ import { SurveyNotificationService, SurveyNotificationServicePath, } from '../common/protocol/survey-service'; +import { CoreErrorHandler } from './contributions/core-error-handler'; +import { CompilerErrors } from './contributions/compiler-errors'; MonacoThemingService.register({ id: 'arduino-theme', @@ -428,6 +430,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { ) ) .inSingletonScope(); + bind(CoreErrorHandler).toSelf().inSingletonScope(); // Serial monitor bind(MonitorWidget).toSelf(); @@ -688,6 +691,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { Contribution.configure(bind, AddZipLibrary); Contribution.configure(bind, PlotterFrontendContribution); Contribution.configure(bind, Format); + Contribution.configure(bind, CompilerErrors); // Disabled the quick-pick customization from Theia when multiple formatters are available. // Use the default VS Code behavior, and pick the first one. In the IDE2, clang-format has `exclusive` selectors. diff --git a/arduino-ide-extension/src/browser/arduino-preferences.ts b/arduino-ide-extension/src/browser/arduino-preferences.ts index 8450012a6..285fe992a 100644 --- a/arduino-ide-extension/src/browser/arduino-preferences.ts +++ b/arduino-ide-extension/src/browser/arduino-preferences.ts @@ -13,6 +13,32 @@ export enum UpdateChannel { Stable = 'stable', Nightly = 'nightly', } +export const ErrorRevealStrategyLiterals = [ + /** + * Scroll vertically as necessary and reveal a line. + */ + 'auto', + /** + * Scroll vertically as necessary and reveal a line centered vertically. + */ + 'center', + /** + * Scroll vertically as necessary and reveal a line close to the top of the viewport, optimized for viewing a code definition. + */ + 'top', + /** + * Scroll vertically as necessary and reveal a line centered vertically only if it lies outside the viewport. + */ + 'centerIfOutsideViewport', +] as const; +export type ErrorRevealStrategy = typeof ErrorRevealStrategyLiterals[number]; +export namespace ErrorRevealStrategy { + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any + export function is(arg: any): arg is ErrorRevealStrategy { + return !!arg && ErrorRevealStrategyLiterals.includes(arg); + } + export const Default: ErrorRevealStrategy = 'centerIfOutsideViewport'; +} export const ArduinoConfigSchema: PreferenceSchema = { type: 'object', @@ -33,6 +59,23 @@ export const ArduinoConfigSchema: PreferenceSchema = { ), default: false, }, + 'arduino.compile.experimental': { + type: 'boolean', + description: nls.localize( + 'arduino/preferences/compile.experimental', + 'True if the IDE should handle multiple compiler errors. False by default' + ), + default: false, + }, + 'arduino.compile.revealRange': { + enum: [...ErrorRevealStrategyLiterals], + description: nls.localize( + 'arduino/preferences/compile.revealRange', + "Adjusts how compiler errors are revealed in the editor after a failed verify/upload. Possible values: 'auto': Scroll vertically as necessary and reveal a line. 'center': Scroll vertically as necessary and reveal a line centered vertically. 'top': Scroll vertically as necessary and reveal a line close to the top of the viewport, optimized for viewing a code definition. 'centerIfOutsideViewport': Scroll vertically as necessary and reveal a line centered vertically only if it lies outside the viewport. The default value is '{0}'.", + ErrorRevealStrategy.Default + ), + default: ErrorRevealStrategy.Default, + }, 'arduino.compile.warnings': { enum: [...CompilerWarningLiterals], description: nls.localize( @@ -188,6 +231,8 @@ export const ArduinoConfigSchema: PreferenceSchema = { export interface ArduinoConfiguration { 'arduino.language.log': boolean; 'arduino.compile.verbose': boolean; + 'arduino.compile.experimental': boolean; + 'arduino.compile.revealRange': ErrorRevealStrategy; 'arduino.compile.warnings': CompilerWarnings; 'arduino.upload.verbose': boolean; 'arduino.upload.verify': boolean; diff --git a/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts b/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts index 17cf91f30..ef2ec75b1 100644 --- a/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts +++ b/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts @@ -1,11 +1,9 @@ import { inject, injectable } from '@theia/core/shared/inversify'; -import { OutputChannelManager } from '@theia/output/lib/browser/output-channel'; -import { CoreService } from '../../common/protocol'; import { ArduinoMenus } from '../menu/arduino-menus'; import { BoardsDataStore } from '../boards/boards-data-store'; import { BoardsServiceProvider } from '../boards/boards-service-provider'; import { - SketchContribution, + CoreServiceContribution, Command, CommandRegistry, MenuModelRegistry, @@ -13,20 +11,13 @@ import { import { nls } from '@theia/core/lib/common'; @injectable() -export class BurnBootloader extends SketchContribution { - @inject(CoreService) - protected readonly coreService: CoreService; - - +export class BurnBootloader extends CoreServiceContribution { @inject(BoardsDataStore) protected readonly boardsDataStore: BoardsDataStore; @inject(BoardsServiceProvider) protected readonly boardsServiceClientImpl: BoardsServiceProvider; - @inject(OutputChannelManager) - protected override readonly outputChannelManager: OutputChannelManager; - override registerCommands(registry: CommandRegistry): void { registry.registerCommand(BurnBootloader.Commands.BURN_BOOTLOADER, { execute: () => this.burnBootloader(), @@ -62,7 +53,7 @@ export class BurnBootloader extends SketchContribution { ...boardsConfig.selectedBoard, name: boardsConfig.selectedBoard?.name || '', fqbn, - } + }; this.outputChannelManager.getChannel('Arduino').clear(); await this.coreService.burnBootloader({ board, @@ -81,13 +72,7 @@ export class BurnBootloader extends SketchContribution { } ); } catch (e) { - let errorMessage = ""; - if (typeof e === "string") { - errorMessage = e; - } else { - errorMessage = e.toString(); - } - this.messageService.error(errorMessage); + this.handleError(e); } } } diff --git a/arduino-ide-extension/src/browser/contributions/compiler-errors.ts b/arduino-ide-extension/src/browser/contributions/compiler-errors.ts new file mode 100644 index 000000000..728341110 --- /dev/null +++ b/arduino-ide-extension/src/browser/contributions/compiler-errors.ts @@ -0,0 +1,656 @@ +import { + Command, + CommandRegistry, + Disposable, + DisposableCollection, + Emitter, + MaybePromise, + nls, + notEmpty, +} from '@theia/core'; +import { ApplicationShell, FrontendApplication } from '@theia/core/lib/browser'; +import URI from '@theia/core/lib/common/uri'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { + Location, + Range, +} from '@theia/core/shared/vscode-languageserver-protocol'; +import { + EditorWidget, + TextDocumentChangeEvent, +} from '@theia/editor/lib/browser'; +import { + EditorDecoration, + TrackedRangeStickiness, +} from '@theia/editor/lib/browser/decorations/editor-decoration'; +import { EditorManager } from '@theia/editor/lib/browser/editor-manager'; +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 { CoreError } from '../../common/protocol/core-service'; +import { + ArduinoPreferences, + ErrorRevealStrategy, +} from '../arduino-preferences'; +import { InoSelector } from '../ino-selectors'; +import { fullRange } from '../utils/monaco'; +import { Contribution } from './contribution'; +import { CoreErrorHandler } from './core-error-handler'; + +interface ErrorDecoration { + /** + * This is the unique ID of the decoration given by `monaco`. + */ + readonly id: string; + /** + * The resource this decoration belongs to. + */ + readonly uri: string; +} +namespace ErrorDecoration { + export function rangeOf( + { id, uri }: ErrorDecoration, + editorProvider: (uri: string) => Promise + ): Promise; + export function rangeOf( + { id, uri }: ErrorDecoration, + editorProvider: MonacoEditor + ): monaco.Range | undefined; + export function rangeOf( + { id, uri }: ErrorDecoration, + editorProvider: + | ((uri: string) => Promise) + | MonacoEditor + ): MaybePromise { + 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; + } + return undefined; + } + return editorProvider(uri).then((editor) => { + if (editor) { + return rangeOf({ id, uri }, editor); + } + return undefined; + }); + } + + // export async function rangeOf( + // { id, uri }: ErrorDecoration, + // editorProvider: + // | ((uri: string) => Promise) + // | MonacoEditor + // ): Promise { + // 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; + } +} + +@injectable() +export class CompilerErrors + extends Contribution + implements monaco.languages.CodeLensProvider +{ + @inject(EditorManager) + private readonly editorManager: EditorManager; + + @inject(ProtocolToMonacoConverter) + private readonly p2m: ProtocolToMonacoConverter; + + @inject(MonacoToProtocolConverter) + private readonly mp2: MonacoToProtocolConverter; + + @inject(CoreErrorHandler) + private readonly coreErrorHandler: CoreErrorHandler; + + @inject(ArduinoPreferences) + private readonly preferences: ArduinoPreferences; + + private readonly errors: ErrorDecoration[] = []; + private readonly onDidChangeEmitter = new monaco.Emitter(); + private readonly currentErrorDidChangEmitter = new Emitter(); + private readonly onCurrentErrorDidChange = + 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; + if (!current) { + return -1; + } + return this.errors.findIndex((error) => + ErrorDecoration.sameAs(error, current) + ); + } + + override onStart(app: FrontendApplication): void { + this.shell = app.shell; + monaco.languages.registerCodeLensProvider(InoSelector, this); + this.coreErrorHandler.onCompilerErrorsDidChange((errors) => + this.filter(errors).then(this.handleCompilerErrorsDidChange.bind(this)) + ); + this.onCurrentErrorDidChange(async (error) => { + const range = await ErrorDecoration.rangeOf(error, (uri) => + this.monacoEditor(uri) + ); + if (!range) { + console.warn( + 'compiler-errors', + `Could not find range of decoration: ${error.id}` + ); + return; + } + const editor = await this.revealLocationInEditor({ + uri: error.uri, + range: this.mp2.asRange(range), + }); + if (!editor) { + console.warn( + 'compiler-errors', + `Failed to mark error ${error.id} as the current one.` + ); + } + }); + this.preferences.ready.then(() => { + this.preferences.onPreferenceChanged(({ preferenceName, newValue }) => { + if (preferenceName === 'arduino.compile.revealRange') { + this.revealStrategy = ErrorRevealStrategy.is(newValue) + ? newValue + : ErrorRevealStrategy.Default; + } + }); + }); + } + + override registerCommands(registry: CommandRegistry): void { + registry.registerCommand(CompilerErrors.Commands.NEXT_ERROR, { + execute: () => { + const index = this.currentErrorIndex; + if (index < 0) { + console.warn( + 'compiler-errors', + `Could not advance to next error. Unknown current error.` + ); + return; + } + const nextError = + this.errors[index === this.errors.length - 1 ? 0 : index + 1]; + this.markAsCurrentError(nextError); + }, + isEnabled: () => !!this.currentError && this.errors.length > 1, + }); + registry.registerCommand(CompilerErrors.Commands.PREVIOUS_ERROR, { + execute: () => { + const index = this.currentErrorIndex; + if (index < 0) { + console.warn( + 'compiler-errors', + `Could not advance to previous error. Unknown current error.` + ); + return; + } + const previousError = + this.errors[index === 0 ? this.errors.length - 1 : index - 1]; + this.markAsCurrentError(previousError); + }, + isEnabled: () => !!this.currentError && this.errors.length > 1, + }); + } + + get onDidChange(): monaco.IEvent { + return this.onDidChangeEmitter.event; + } + + async provideCodeLenses( + model: monaco.editor.ITextModel, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _token: monaco.CancellationToken + ): Promise { + const lenses: monaco.languages.CodeLens[] = []; + if ( + this.currentError && + this.currentError.uri === model.uri.toString() && + this.errors.length > 1 + ) { + const range = await ErrorDecoration.rangeOf(this.currentError, (uri) => + this.monacoEditor(uri) + ); + if (range) { + lenses.push( + { + range, + command: { + id: CompilerErrors.Commands.PREVIOUS_ERROR.id, + title: nls.localize( + 'arduino/editor/previousError', + 'Previous Error' + ), + arguments: [this.currentError], + }, + }, + { + range, + command: { + id: CompilerErrors.Commands.NEXT_ERROR.id, + title: nls.localize('arduino/editor/nextError', 'Next Error'), + arguments: [this.currentError], + }, + } + ); + } + } + return { + lenses, + dispose: () => { + /* NOOP */ + }, + }; + } + + private async handleCompilerErrorsDidChange( + errors: CoreError.Compiler[] + ): Promise { + this.toDisposeOnCompilerErrorDidChange.dispose(); + const compilerErrorsPerResource = this.groupByResource( + await this.filter(errors) + ); + const decorations = await this.decorateEditors(compilerErrorsPerResource); + this.errors.push(...decorations.errors); + this.toDisposeOnCompilerErrorDidChange.pushAll([ + Disposable.create(() => (this.errors.length = 0)), + Disposable.create(() => this.onDidChangeEmitter.fire(this)), + ...(await Promise.all([ + decorations.dispose, + this.trackEditors( + compilerErrorsPerResource, + (editor) => + editor.editor.onSelectionChanged((selection) => + this.handleSelectionChange(editor, selection) + ), + (editor) => + editor.onDidDispose(() => + this.handleEditorDidDispose(editor.editor.uri.toString()) + ), + (editor) => + editor.editor.onDocumentContentChanged((event) => + this.handleDocumentContentChange(editor, event) + ) + ), + ])), + ]); + const currentError = this.errors[0]; + if (currentError) { + await this.markAsCurrentError(currentError); + } + } + + private async filter( + errors: CoreError.Compiler[] + ): Promise { + if (!errors.length) { + return []; + } + await this.preferences.ready; + if (this.preferences['arduino.compile.experimental']) { + return errors; + } + // Always shows maximum one error; hence the code lens navigation is unavailable. + return [errors[0]]; + } + + private async decorateEditors( + errors: Map + ): Promise<{ dispose: Disposable; errors: ErrorDecoration[] }> { + const composite = await Promise.all( + [...errors.entries()].map(([uri, errors]) => + this.decorateEditor(uri, errors) + ) + ); + return { + dispose: new DisposableCollection( + ...composite.map(({ dispose }) => dispose) + ), + errors: composite.reduce( + (acc, { errors }) => acc.concat(errors), + [] as ErrorDecoration[] + ), + }; + } + + private async decorateEditor( + uri: string, + errors: CoreError.Compiler[] + ): Promise<{ dispose: Disposable; errors: ErrorDecoration[] }> { + const editor = await this.editorManager.getByUri(new URI(uri)); + if (!editor) { + return { dispose: Disposable.NULL, errors: [] }; + } + const oldDecorations = editor.editor.deltaDecorations({ + oldDecorations: [], + newDecorations: errors.map((error) => + this.compilerErrorDecoration(error.location.range) + ), + }); + return { + dispose: Disposable.create(() => { + if (editor) { + editor.editor.deltaDecorations({ + oldDecorations, + newDecorations: [], + }); + } + }), + errors: oldDecorations.map((id) => ({ id, uri })), + }; + } + + private compilerErrorDecoration(range: Range): EditorDecoration { + return { + range, + options: { + isWholeLine: true, + className: 'compiler-error', + stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges, + }, + }; + } + + /** + * 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; + } + const uri = monacoEditor.uri.toString(); + const monacoSelection = this.p2m.asRange(selection); + console.log( + 'compiler-errors', + `Handling selection change in editor ${uri}. New (monaco) selection: ${monacoSelection.toJSON()}` + ); + const calculatePriority = ( + candidateErrorRange: monaco.Range, + currentSelection: monaco.Range + ) => { + console.trace( + 'compiler-errors', + `Candidate error range: ${candidateErrorRange.toJSON()}` + ); + console.trace( + 'compiler-errors', + `Current selection range: ${currentSelection.toJSON()}` + ); + if (candidateErrorRange.intersectRanges(currentSelection)) { + console.trace('Intersects.'); + return { score: 2 }; + } + if ( + candidateErrorRange.startLineNumber <= + currentSelection.startLineNumber && + candidateErrorRange.endLineNumber >= currentSelection.endLineNumber + ) { + console.trace('Same line.'); + return { score: 1 }; + } + + console.trace('No match'); + return undefined; + }; + const error = this.errors + .filter((error) => error.uri === uri) + .map((error) => ({ + error, + range: ErrorDecoration.rangeOf(error, monacoEditor), + })) + .map(({ error, range }) => { + if (range) { + const priority = calculatePriority(range, monacoSelection); + if (priority) { + return { ...priority, error }; + } + } + return undefined; + }) + .filter(notEmpty) + .sort((left, right) => right.score - left.score) // highest first + .map(({ error }) => error) + .shift(); + if (error) { + this.markAsCurrentError(error); + } else { + console.info( + 'compiler-errors', + `New (monaco) selection ${monacoSelection.toJSON()} does not intersect any error locations. Skipping.` + ); + } + } + + /** + * This code does not deal with resource deletion, but tracks editor dispose events. It does not matter what was the cause of the editor disposal. + * If editor closes, delete the decorators. + */ + private handleEditorDidDispose(uri: string): void { + let i = this.errors.length; + // `splice` re-indexes the array. It's better to "iterate and modify" from the last element. + while (i--) { + const error = this.errors[i]; + if (error.uri === uri) { + this.errors.splice(i, 1); + } + } + this.onDidChangeEmitter.fire(this); + } + + /** + * If a document change "destroys" the range of the decoration, the decoration must be removed. + */ + private handleDocumentContentChange( + editor: EditorWidget, + 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 + ); + if (!relevantChanges.length) { + return; + } + + 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 + .map(({ range }) => this.p2m.asRange(range)) + .map((changeRange) => + resolvedMarkers.filter(({ range: decorationRange }) => + changeRange.containsRange(decorationRange) + ) + ) + .reduce((acc, curr) => acc.concat(curr), []) + .map(({ error, index }) => { + this.errors.splice(index, 1); + return error.id; + }); + if (!decorationIdsToRemove.length) { + return; + } + monacoEditor.getControl().deltaDecorations(decorationIdsToRemove, []); + this.onDidChangeEmitter.fire(this); + } + + private async trackEditors( + errors: Map, + ...track: ((editor: EditorWidget) => Disposable)[] + ): Promise { + return new DisposableCollection( + ...(await Promise.all( + Array.from(errors.keys()).map(async (uri) => { + const editor = await this.editorManager.getByUri(new URI(uri)); + if (!editor) { + return Disposable.NULL; + } + return new DisposableCollection(...track.map((t) => t(editor))); + }) + )) + ); + } + + private async markAsCurrentError(error: ErrorDecoration): Promise { + const index = this.errors.findIndex((candidate) => + ErrorDecoration.sameAs(candidate, error) + ); + if (index < 0) { + console.warn( + 'compiler-errors', + `Failed to mark error ${ + error.id + } as the current one. Error is unknown. Known errors are: ${this.errors.map( + ({ id }) => id + )}` + ); + return; + } + const newError = this.errors[index]; + if ( + !this.currentError || + !ErrorDecoration.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); + this.onDidChangeEmitter.fire(this); + } + } + + // The double editor activation logic is required: https://github.com/eclipse-theia/theia/issues/11284 + private async revealLocationInEditor( + location: Location + ): Promise { + const { uri, range } = location; + const editor = await this.editorManager.getByUri(new URI(uri), { + mode: 'activate', + }); + if (editor && this.shell) { + // to avoid flickering, reveal the range here and not with `getByUri`, because it uses `at: 'center'` for the reveal option. + // TODO: check the community reaction whether it is better to set the focus at the error marker. it might cause flickering even if errors are close to each other + editor.editor.revealRange(range, { at: this.revealStrategy }); + const activeWidget = await this.shell.activateWidget(editor.id); + if (!activeWidget) { + console.warn( + 'compiler-errors', + `editor widget activation has failed. editor widget ${editor.id} expected to be the active one.` + ); + return editor; + } + if (editor !== activeWidget) { + console.warn( + 'compiler-errors', + `active widget was not the same as previously activated editor. editor widget ID ${editor.id}, active widget ID: ${activeWidget.id}` + ); + } + return editor; + } + console.warn( + 'compiler-errors', + `could not found editor widget for URI: ${uri}` + ); + return undefined; + } + + private groupByResource( + errors: CoreError.Compiler[] + ): Map { + return errors.reduce((acc, curr) => { + const { + location: { uri }, + } = curr; + let errors = acc.get(uri); + if (!errors) { + errors = []; + acc.set(uri, errors); + } + errors.push(curr); + return acc; + }, new Map()); + } + + private monacoEditor(widget: EditorWidget): MonacoEditor | undefined; + private monacoEditor(uri: string): Promise; + private monacoEditor( + uriOrWidget: string | EditorWidget + ): MaybePromise { + if (uriOrWidget instanceof EditorWidget) { + const editor = uriOrWidget.editor; + if (editor instanceof MonacoEditor) { + return editor; + } + return undefined; + } else { + return this.editorManager + .getByUri(new URI(uriOrWidget)) + .then((editor) => { + if (editor) { + return this.monacoEditor(editor); + } + return undefined; + }); + } + } +} +export namespace CompilerErrors { + export namespace Commands { + export const NEXT_ERROR: Command = { + id: 'arduino-editor-next-error', + }; + export const PREVIOUS_ERROR: Command = { + id: 'arduino-editor-previous-error', + }; + } +} diff --git a/arduino-ide-extension/src/browser/contributions/contribution.ts b/arduino-ide-extension/src/browser/contributions/contribution.ts index 1597cac28..fc51d5b65 100644 --- a/arduino-ide-extension/src/browser/contributions/contribution.ts +++ b/arduino-ide-extension/src/browser/contributions/contribution.ts @@ -14,7 +14,7 @@ import { EditorManager } from '@theia/editor/lib/browser/editor-manager'; import { MessageService } from '@theia/core/lib/common/message-service'; import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { open, OpenerService } from '@theia/core/lib/browser/opener-service'; -import { OutputChannelManager } from '@theia/output/lib/browser/output-channel'; + import { MenuModelRegistry, MenuContribution, @@ -48,9 +48,15 @@ import { ConfigService, FileSystemExt, Sketch, + CoreService, + CoreError, } from '../../common/protocol'; import { ArduinoPreferences } from '../arduino-preferences'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; +import { CoreErrorHandler } from './core-error-handler'; +import { nls } from '@theia/core'; +import { OutputChannelManager } from '../theia/output/output-channel'; +import { ClipboardService } from '@theia/core/lib/browser/clipboard-service'; export { Command, @@ -164,6 +170,56 @@ export abstract class SketchContribution extends Contribution { } } +@injectable() +export class CoreServiceContribution extends SketchContribution { + @inject(CoreService) + protected readonly coreService: CoreService; + + @inject(CoreErrorHandler) + protected readonly coreErrorHandler: CoreErrorHandler; + + @inject(ClipboardService) + private readonly clipboardService: ClipboardService; + + protected handleError(error: unknown): void { + this.coreErrorHandler.tryHandle(error); + this.tryToastErrorMessage(error); + } + + private tryToastErrorMessage(error: unknown): void { + let message: undefined | string = undefined; + if (CoreError.is(error)) { + message = error.message; + } else if (error instanceof Error) { + message = error.message; + } else if (typeof error === 'string') { + message = error; + } else { + try { + message = JSON.stringify(error); + } catch {} + } + if (message) { + const copyAction = nls.localize( + 'arduino/coreContribution/copyError', + 'Copy error messages' + ); + this.messageService.error(message, copyAction).then(async (action) => { + if (action === copyAction) { + const content = await this.outputChannelManager.contentOfChannel( + 'Arduino' + ); + if (content) { + this.clipboardService.writeText(content); + } + } + }); + } else { + throw error; + } + } +} + export namespace Contribution { export function configure( bind: interfaces.Bind, diff --git a/arduino-ide-extension/src/browser/contributions/core-error-handler.ts b/arduino-ide-extension/src/browser/contributions/core-error-handler.ts new file mode 100644 index 000000000..9eec07cd6 --- /dev/null +++ b/arduino-ide-extension/src/browser/contributions/core-error-handler.ts @@ -0,0 +1,32 @@ +import { Emitter, Event } from '@theia/core'; +import { injectable } from '@theia/core/shared/inversify'; +import { CoreError } from '../../common/protocol/core-service'; + +@injectable() +export class CoreErrorHandler { + private readonly compilerErrors: CoreError.Compiler[] = []; + private readonly compilerErrorsDidChangeEmitter = new Emitter< + CoreError.Compiler[] + >(); + + tryHandle(error: unknown): void { + if (CoreError.is(error)) { + this.compilerErrors.length = 0; + this.compilerErrors.push(...error.data.filter(CoreError.Compiler.is)); + this.fireCompilerErrorsDidChange(); + } + } + + reset(): void { + this.compilerErrors.length = 0; + this.fireCompilerErrorsDidChange(); + } + + get onCompilerErrorsDidChange(): Event { + return this.compilerErrorsDidChangeEmitter.event; + } + + private fireCompilerErrorsDidChange(): void { + this.compilerErrorsDidChangeEmitter.fire(this.compilerErrors.slice()); + } +} diff --git a/arduino-ide-extension/src/browser/contributions/format.ts b/arduino-ide-extension/src/browser/contributions/format.ts index 17f1edf0a..e270181b0 100644 --- a/arduino-ide-extension/src/browser/contributions/format.ts +++ b/arduino-ide-extension/src/browser/contributions/format.ts @@ -2,6 +2,8 @@ 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 { Contribution, URI } from './contribution'; @injectable() @@ -15,12 +17,11 @@ export class Format private readonly formatter: Formatter; override onStart(): MaybePromise { - const selector = this.selectorOf('ino', 'c', 'cpp', 'h', 'hpp', 'pde'); monaco.languages.registerDocumentRangeFormattingEditProvider( - selector, + InoSelector, this ); - monaco.languages.registerDocumentFormattingEditProvider(selector, this); + monaco.languages.registerDocumentFormattingEditProvider(InoSelector, this); } async provideDocumentRangeFormattingEdits( model: monaco.editor.ITextModel, @@ -39,18 +40,11 @@ export class Format // eslint-disable-next-line @typescript-eslint/no-unused-vars _token: monaco.CancellationToken ): Promise { - const range = this.fullRange(model); + const range = fullRange(model); const text = await this.format(model, range, options); return [{ range, text }]; } - private 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); - } - /** * From the currently opened workspaces (IDE2 has always one), it calculates all possible * folder locations where the `.clang-format` file could be. @@ -82,13 +76,4 @@ export class Format options, }); } - - private selectorOf( - ...languageId: string[] - ): monaco.languages.LanguageSelector { - return languageId.map((language) => ({ - language, - exclusive: true, // <-- this should make sure the custom formatter has higher precedence over the LS formatter. - })); - } } diff --git a/arduino-ide-extension/src/browser/contributions/upload-sketch.ts b/arduino-ide-extension/src/browser/contributions/upload-sketch.ts index 76a8f4973..ebfd02c6d 100644 --- a/arduino-ide-extension/src/browser/contributions/upload-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/upload-sketch.ts @@ -6,7 +6,7 @@ import { ArduinoToolbar } from '../toolbar/arduino-toolbar'; import { BoardsDataStore } from '../boards/boards-data-store'; import { BoardsServiceProvider } from '../boards/boards-service-provider'; import { - SketchContribution, + CoreServiceContribution, Command, CommandRegistry, MenuModelRegistry, @@ -18,10 +18,7 @@ import { DisposableCollection, nls } from '@theia/core/lib/common'; import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl'; @injectable() -export class UploadSketch extends SketchContribution { - @inject(CoreService) - protected readonly coreService: CoreService; - +export class UploadSketch extends CoreServiceContribution { @inject(MenuModelRegistry) protected readonly menuRegistry: MenuModelRegistry; @@ -201,16 +198,17 @@ export class UploadSketch extends SketchContribution { return; } - // toggle the toolbar button and menu item state. - // uploadInProgress will be set to false whether the upload fails or not - this.uploadInProgress = true; - this.onDidChangeEmitter.fire(); const sketch = await this.sketchServiceClient.currentSketch(); if (!CurrentSketch.isValid(sketch)) { return; } try { + // toggle the toolbar button and menu item state. + // uploadInProgress will be set to false whether the upload fails or not + this.uploadInProgress = true; + this.coreErrorHandler.reset(); + this.onDidChangeEmitter.fire(); const { boardsConfig } = this.boardsServiceClientImpl; const [fqbn, { selectedProgrammer }, verify, verbose, sourceOverride] = await Promise.all([ @@ -227,9 +225,8 @@ export class UploadSketch extends SketchContribution { ...boardsConfig.selectedBoard, name: boardsConfig.selectedBoard?.name || '', fqbn, - } + }; let options: CoreService.Upload.Options | undefined = undefined; - const sketchUri = sketch.uri; const optimizeForDebug = this.editorMode.compileForDebug; const { selectedPort } = boardsConfig; const port = selectedPort; @@ -248,7 +245,7 @@ export class UploadSketch extends SketchContribution { if (usingProgrammer) { const programmer = selectedProgrammer; options = { - sketchUri, + sketch, board, optimizeForDebug, programmer, @@ -260,7 +257,7 @@ export class UploadSketch extends SketchContribution { }; } else { options = { - sketchUri, + sketch, board, optimizeForDebug, port, @@ -281,13 +278,7 @@ export class UploadSketch extends SketchContribution { { timeout: 3000 } ); } catch (e) { - let errorMessage = ''; - if (typeof e === 'string') { - errorMessage = e; - } else { - errorMessage = e.toString(); - } - this.messageService.error(errorMessage); + this.handleError(e); } finally { this.uploadInProgress = false; this.onDidChangeEmitter.fire(); diff --git a/arduino-ide-extension/src/browser/contributions/verify-sketch.ts b/arduino-ide-extension/src/browser/contributions/verify-sketch.ts index fdc5b504f..b7f391bd5 100644 --- a/arduino-ide-extension/src/browser/contributions/verify-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/verify-sketch.ts @@ -1,12 +1,11 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { Emitter } from '@theia/core/lib/common/event'; -import { CoreService } from '../../common/protocol'; import { ArduinoMenus } from '../menu/arduino-menus'; import { ArduinoToolbar } from '../toolbar/arduino-toolbar'; import { BoardsDataStore } from '../boards/boards-data-store'; import { BoardsServiceProvider } from '../boards/boards-service-provider'; import { - SketchContribution, + CoreServiceContribution, Command, CommandRegistry, MenuModelRegistry, @@ -17,10 +16,7 @@ import { nls } from '@theia/core/lib/common'; import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl'; @injectable() -export class VerifySketch extends SketchContribution { - @inject(CoreService) - protected readonly coreService: CoreService; - +export class VerifySketch extends CoreServiceContribution { @inject(BoardsDataStore) protected readonly boardsDataStore: BoardsDataStore; @@ -96,14 +92,14 @@ export class VerifySketch extends SketchContribution { // toggle the toolbar button and menu item state. // verifyInProgress will be set to false whether the compilation fails or not - this.verifyInProgress = true; - this.onDidChangeEmitter.fire(); const sketch = await this.sketchServiceClient.currentSketch(); - if (!CurrentSketch.isValid(sketch)) { return; } try { + this.verifyInProgress = true; + this.coreErrorHandler.reset(); + this.onDidChangeEmitter.fire(); const { boardsConfig } = this.boardsServiceClientImpl; const [fqbn, sourceOverride] = await Promise.all([ this.boardsDataStore.appendConfigToFqbn( @@ -115,12 +111,12 @@ export class VerifySketch extends SketchContribution { ...boardsConfig.selectedBoard, name: boardsConfig.selectedBoard?.name || '', fqbn, - } + }; const verbose = this.preferences.get('arduino.compile.verbose'); const compilerWarnings = this.preferences.get('arduino.compile.warnings'); this.outputChannelManager.getChannel('Arduino').clear(); await this.coreService.compile({ - sketchUri: sketch.uri, + sketch, board, optimizeForDebug: this.editorMode.compileForDebug, verbose, @@ -133,13 +129,7 @@ export class VerifySketch extends SketchContribution { { timeout: 3000 } ); } catch (e) { - let errorMessage = ""; - if (typeof e === "string") { - errorMessage = e; - } else { - errorMessage = e.toString(); - } - this.messageService.error(errorMessage); + this.handleError(e); } finally { this.verifyInProgress = false; this.onDidChangeEmitter.fire(); diff --git a/arduino-ide-extension/src/browser/ino-selectors.ts b/arduino-ide-extension/src/browser/ino-selectors.ts new file mode 100644 index 000000000..e413dba26 --- /dev/null +++ b/arduino-ide-extension/src/browser/ino-selectors.ts @@ -0,0 +1,13 @@ +import * as monaco from '@theia/monaco-editor-core'; +/** + * Exclusive "ino" document selector for monaco. + */ +export const InoSelector = selectorOf('ino', 'c', 'cpp', 'h', 'hpp', 'pde'); +function selectorOf( + ...languageId: string[] +): monaco.languages.LanguageSelector { + return languageId.map((language) => ({ + language, + exclusive: true, // <-- this should make sure the custom formatter has higher precedence over the LS formatter. + })); +} diff --git a/arduino-ide-extension/src/browser/response-service-impl.ts b/arduino-ide-extension/src/browser/response-service-impl.ts index 621364a59..c50506c86 100644 --- a/arduino-ide-extension/src/browser/response-service-impl.ts +++ b/arduino-ide-extension/src/browser/response-service-impl.ts @@ -1,7 +1,9 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { Emitter } from '@theia/core/lib/common/event'; -import { OutputContribution } from '@theia/output/lib/browser/output-contribution'; -import { OutputChannelManager } from '@theia/output/lib/browser/output-channel'; +import { + OutputChannelManager, + OutputChannelSeverity, +} from '@theia/output/lib/browser/output-channel'; import { OutputMessage, ProgressMessage, @@ -10,13 +12,10 @@ import { @injectable() export class ResponseServiceImpl implements ResponseServiceArduino { - @inject(OutputContribution) - protected outputContribution: OutputContribution; - @inject(OutputChannelManager) - protected outputChannelManager: OutputChannelManager; + private readonly outputChannelManager: OutputChannelManager; - protected readonly progressDidChangeEmitter = new Emitter(); + private readonly progressDidChangeEmitter = new Emitter(); readonly onProgressDidChange = this.progressDidChangeEmitter.event; @@ -25,13 +24,22 @@ export class ResponseServiceImpl implements ResponseServiceArduino { } appendToOutput(message: OutputMessage): void { - const { chunk } = message; + const { chunk, severity } = message; const channel = this.outputChannelManager.getChannel('Arduino'); channel.show({ preserveFocus: true }); - channel.append(chunk); + channel.append(chunk, mapSeverity(severity)); } reportProgress(progress: ProgressMessage): void { this.progressDidChangeEmitter.fire(progress); } } + +function mapSeverity(severity?: OutputMessage.Severity): OutputChannelSeverity { + if (severity === OutputMessage.Severity.Error) { + return OutputChannelSeverity.Error; + } else if (severity === OutputMessage.Severity.Warning) { + return OutputChannelSeverity.Warning; + } + return OutputChannelSeverity.Info; +} diff --git a/arduino-ide-extension/src/browser/style/editor.css b/arduino-ide-extension/src/browser/style/editor.css index 5be2d405d..484e521c9 100644 --- a/arduino-ide-extension/src/browser/style/editor.css +++ b/arduino-ide-extension/src/browser/style/editor.css @@ -8,3 +8,8 @@ .monaco-list-row.show-file-icons.focused { background-color: #d6ebff; } + +.monaco-editor .view-overlays .compiler-error { + background-color: var(--theia-inputValidation-errorBackground); + opacity: 0.4 !important; +} diff --git a/arduino-ide-extension/src/browser/theia/output/output-channel.ts b/arduino-ide-extension/src/browser/theia/output/output-channel.ts index b928dce06..aa1c8206c 100644 --- a/arduino-ide-extension/src/browser/theia/output/output-channel.ts +++ b/arduino-ide-extension/src/browser/theia/output/output-channel.ts @@ -40,6 +40,14 @@ export class OutputChannelManager extends TheiaOutputChannelManager { } return channel; } + + async contentOfChannel(name: string): Promise { + const resource = this.resources.get(name); + if (resource) { + return resource.readContents(); + } + return undefined; + } } export class OutputChannel extends TheiaOutputChannel { diff --git a/arduino-ide-extension/src/browser/utils/monaco.ts b/arduino-ide-extension/src/browser/utils/monaco.ts new file mode 100644 index 000000000..0e957a980 --- /dev/null +++ b/arduino-ide-extension/src/browser/utils/monaco.ts @@ -0,0 +1,8 @@ +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 15aa85bb0..a783eae28 100644 --- a/arduino-ide-extension/src/common/protocol/core-service.ts +++ b/arduino-ide-extension/src/common/protocol/core-service.ts @@ -1,6 +1,10 @@ +import { ApplicationError } from '@theia/core'; +import { Location } from '@theia/core/shared/vscode-languageserver-protocol'; import { BoardUserField } from '.'; import { Board, Port } from '../../common/protocol/boards-service'; +import { ErrorInfo as CliErrorInfo } from '../../node/cli-error-parser'; import { Programmer } from './boards-service'; +import { Sketch } from './sketches-service'; export const CompilerWarningLiterals = [ 'None', @@ -9,6 +13,53 @@ export const CompilerWarningLiterals = [ 'All', ] as const; export type CompilerWarnings = typeof CompilerWarningLiterals[number]; +export namespace CoreError { + export type ErrorInfo = CliErrorInfo; + export interface Compiler extends ErrorInfo { + readonly message: string; + readonly location: Location; + } + export namespace Compiler { + export function is(error: ErrorInfo): error is Compiler { + const { message, location } = error; + return !!message && !!location; + } + } + export const Codes = { + Verify: 4001, + Upload: 4002, + UploadUsingProgrammer: 4003, + BurnBootloader: 4004, + }; + export const VerifyFailed = create(Codes.Verify); + export const UploadFailed = create(Codes.Upload); + export const UploadUsingProgrammerFailed = create( + Codes.UploadUsingProgrammer + ); + export const BurnBootloaderFailed = create(Codes.BurnBootloader); + export function is( + error: unknown + ): error is ApplicationError { + return ( + error instanceof Error && + ApplicationError.is(error) && + Object.values(Codes).includes(error.code) + ); + } + function create( + code: number + ): ApplicationError.Constructor { + return ApplicationError.declare( + code, + (message: string, data: ErrorInfo[]) => { + return { + data, + message, + }; + } + ); + } +} export const CoreServicePath = '/services/core-service'; export const CoreService = Symbol('CoreService'); @@ -23,16 +74,12 @@ export interface CoreService { upload(options: CoreService.Upload.Options): Promise; uploadUsingProgrammer(options: CoreService.Upload.Options): Promise; burnBootloader(options: CoreService.Bootloader.Options): Promise; - isUploading(): Promise; } export namespace CoreService { export namespace Compile { export interface Options { - /** - * `file` URI to the sketch folder. - */ - readonly sketchUri: string; + readonly sketch: Sketch; readonly board?: Board; readonly optimizeForDebug: boolean; readonly verbose: boolean; diff --git a/arduino-ide-extension/src/common/protocol/response-service.ts b/arduino-ide-extension/src/common/protocol/response-service.ts index 8a31877ab..9c2e4e248 100644 --- a/arduino-ide-extension/src/common/protocol/response-service.ts +++ b/arduino-ide-extension/src/common/protocol/response-service.ts @@ -2,7 +2,14 @@ import { Event } from '@theia/core/lib/common/event'; export interface OutputMessage { readonly chunk: string; - readonly severity?: 'error' | 'warning' | 'info'; // Currently not used! + readonly severity?: OutputMessage.Severity; +} +export namespace OutputMessage { + export enum Severity { + Error, + Warning, + Info, + } } export interface ProgressMessage { diff --git a/arduino-ide-extension/src/common/protocol/sketches-service.ts b/arduino-ide-extension/src/common/protocol/sketches-service.ts index 722ada586..eb07572d7 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service.ts +++ b/arduino-ide-extension/src/common/protocol/sketches-service.ts @@ -127,11 +127,8 @@ export namespace Sketch { export const ALL = Array.from(new Set([...MAIN, ...SOURCE, ...ADDITIONAL])); } export function isInSketch(uri: string | URI, sketch: Sketch): boolean { - const { mainFileUri, otherSketchFileUris, additionalFileUris } = sketch; - return ( - [mainFileUri, ...otherSketchFileUris, ...additionalFileUris].indexOf( - uri.toString() - ) !== -1 + return uris(sketch).includes( + typeof uri === 'string' ? uri : uri.toString() ); } export function isSketchFile(arg: string | URI): boolean { @@ -140,6 +137,10 @@ export namespace Sketch { } return Extensions.MAIN.some((ext) => arg.endsWith(ext)); } + export function uris(sketch: Sketch): string[] { + const { mainFileUri, otherSketchFileUris, additionalFileUris } = sketch; + return [mainFileUri, ...otherSketchFileUris, ...additionalFileUris]; + } } export interface SketchContainer { diff --git a/arduino-ide-extension/src/node/cli-error-parser.ts b/arduino-ide-extension/src/node/cli-error-parser.ts new file mode 100644 index 000000000..25148250f --- /dev/null +++ b/arduino-ide-extension/src/node/cli-error-parser.ts @@ -0,0 +1,234 @@ +import { notEmpty } from '@theia/core'; +import { nls } from '@theia/core/lib/common/nls'; +import { FileUri } from '@theia/core/lib/node/file-uri'; +import { + Location, + Range, + Position, +} from '@theia/core/shared/vscode-languageserver-protocol'; +import { Sketch } from '../common/protocol'; + +export interface ErrorInfo { + readonly message?: string; + readonly location?: Location; + readonly details?: string; +} +export interface ErrorSource { + readonly content: string | ReadonlyArray; + readonly sketch?: Sketch; +} + +export function tryParseError(source: ErrorSource): ErrorInfo[] { + const { content, sketch } = source; + const err = + typeof content === 'string' + ? content + : Buffer.concat(content).toString('utf8'); + if (sketch) { + return tryParse(err) + .map(remapErrorMessages) + .filter(isLocationInSketch(sketch)) + .map(errorInfo()); + } + return []; +} + +interface ParseResult { + readonly path: string; + readonly line: number; + readonly column?: number; + readonly errorPrefix: string; + readonly error: string; + readonly message?: string; +} +namespace ParseResult { + export function keyOf(result: ParseResult): string { + /** + * The CLI compiler might return with the same error multiple times. This is the key function for the distinct set calculation. + */ + return JSON.stringify(result); + } +} + +function isLocationInSketch( + sketch: Sketch +): (value: ParseResult, index: number, array: ParseResult[]) => unknown { + return (result) => { + const uri = FileUri.create(result.path).toString(); + if (!Sketch.isInSketch(uri, sketch)) { + console.warn( + `URI <${uri}> is not contained in sketch: <${JSON.stringify(sketch)}>` + ); + return false; + } + return true; + }; +} + +function errorInfo(): (value: ParseResult) => ErrorInfo { + return ({ error, message, path, line, column }) => ({ + message: error, + details: message, + location: { + uri: FileUri.create(path).toString(), + range: range(line, column), + }, + }); +} + +function range(line: number, column?: number): Range { + const start = Position.create( + line - 1, + typeof column === 'number' ? column - 1 : 0 + ); + return { + start, + end: start, + }; +} + +export function tryParse(raw: 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) + ); + 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(), + ]; +} + +/** + * Converts cryptic and legacy error messages to nice ones. Taken from the Java IDE. + */ +function remapErrorMessages(result: ParseResult): ParseResult { + const knownError = KnownErrors[result.error]; + if (!knownError) { + return result; + } + const { message, error } = knownError; + return { + ...result, + ...(message && { message }), + ...(error && { error }), + }; +} + +// Based on the Java IDE: https://github.com/arduino/Arduino/blob/43b0818f7fa8073301db1b80ac832b7b7596b828/arduino-core/src/cc/arduino/Compiler.java#L528-L578 +const KnownErrors: Record = { + 'SPI.h: No such file or directory': { + error: nls.localize( + 'arduino/cli-error-parser/spiError', + 'Please import the SPI library from the Sketch > Import Library menu.' + ), + message: nls.localize( + 'arduino/cli-error-parser/spiMessage', + 'As of Arduino 0019, the Ethernet library depends on the SPI library.\nYou appear to be using it or another library that depends on the SPI library.' + ), + }, + "'BYTE' was not declared in this scope": { + error: nls.localize( + 'arduino/cli-error-parser/byteError', + "The 'BYTE' keyword is no longer supported." + ), + message: nls.localize( + 'arduino/cli-error-parser/byteMessage', + "As of Arduino 1.0, the 'BYTE' keyword is no longer supported.\nPlease use Serial.write() instead." + ), + }, + "no matching function for call to 'Server::Server(int)'": { + error: nls.localize( + 'arduino/cli-error-parser/serverError', + 'The Server class has been renamed EthernetServer.' + ), + message: nls.localize( + 'arduino/cli-error-parser/serverMessage', + 'As of Arduino 1.0, the Server class in the Ethernet library has been renamed to EthernetServer.' + ), + }, + "no matching function for call to 'Client::Client(byte [4], int)'": { + error: nls.localize( + 'arduino/cli-error-parser/clientError', + 'The Client class has been renamed EthernetClient.' + ), + message: nls.localize( + 'arduino/cli-error-parser/clientMessage', + 'As of Arduino 1.0, the Client class in the Ethernet library has been renamed to EthernetClient.' + ), + }, + "'Udp' was not declared in this scope": { + error: nls.localize( + 'arduino/cli-error-parser/udpError', + 'The Udp class has been renamed EthernetUdp.' + ), + message: nls.localize( + 'arduino/cli-error-parser/udpMessage', + 'As of Arduino 1.0, the Udp class in the Ethernet library has been renamed to EthernetUdp.' + ), + }, + "'class TwoWire' has no member named 'send'": { + error: nls.localize( + 'arduino/cli-error-parser/sendError', + 'Wire.send() has been renamed Wire.write().' + ), + message: nls.localize( + 'arduino/cli-error-parser/sendMessage', + 'As of Arduino 1.0, the Wire.send() function was renamed to Wire.write() for consistency with other libraries.' + ), + }, + "'class TwoWire' has no member named 'receive'": { + error: nls.localize( + 'arduino/cli-error-parser/receiveError', + 'Wire.receive() has been renamed Wire.read().' + ), + message: nls.localize( + 'arduino/cli-error-parser/receiveMessage', + 'As of Arduino 1.0, the Wire.receive() function was renamed to Wire.read() for consistency with other libraries.' + ), + }, + "'Mouse' was not declared in this scope": { + error: nls.localize( + 'arduino/cli-error-parser/mouseError', + "'Mouse' not found. Does your sketch include the line '#include '?" + ), + }, + "'Keyboard' was not declared in this scope": { + error: nls.localize( + 'arduino/cli-error-parser/keyboardError', + "'Keyboard' not found. Does your sketch include the line '#include '?" + ), + }, +}; diff --git a/arduino-ide-extension/src/node/core-service-impl.ts b/arduino-ide-extension/src/node/core-service-impl.ts index 412e64807..f6e39e4e1 100644 --- a/arduino-ide-extension/src/node/core-service-impl.ts +++ b/arduino-ide-extension/src/node/core-service-impl.ts @@ -4,7 +4,11 @@ import { relative } from 'path'; import * as jspb from 'google-protobuf'; import { BoolValue } from 'google-protobuf/google/protobuf/wrappers_pb'; import { ClientReadableStream } from '@grpc/grpc-js'; -import { CompilerWarnings, CoreService } from '../common/protocol/core-service'; +import { + CompilerWarnings, + CoreService, + CoreError, +} from '../common/protocol/core-service'; import { CompileRequest, CompileResponse, @@ -19,27 +23,24 @@ import { UploadUsingProgrammerResponse, } from './cli-protocol/cc/arduino/cli/commands/v1/upload_pb'; import { ResponseService } from '../common/protocol/response-service'; -import { NotificationServiceServer } from '../common/protocol'; +import { Board, OutputMessage, Port, Status } from '../common/protocol'; import { ArduinoCoreServiceClient } from './cli-protocol/cc/arduino/cli/commands/v1/commands_grpc_pb'; -import { firstToUpperCase, firstToLowerCase } from '../common/utils'; -import { Port } from './cli-protocol/cc/arduino/cli/commands/v1/port_pb'; -import { nls } from '@theia/core'; +import { Port as GrpcPort } from './cli-protocol/cc/arduino/cli/commands/v1/port_pb'; +import { ApplicationError, Disposable, nls } from '@theia/core'; import { MonitorManager } from './monitor-manager'; import { SimpleBuffer } from './utils/simple-buffer'; +import { tryParseError } from './cli-error-parser'; +import { Instance } from './cli-protocol/cc/arduino/cli/commands/v1/common_pb'; +import { firstToUpperCase, notEmpty } from '../common/utils'; +import { ServiceError } from './service-error'; -const FLUSH_OUTPUT_MESSAGES_TIMEOUT_MS = 32; @injectable() export class CoreServiceImpl extends CoreClientAware implements CoreService { @inject(ResponseService) - protected readonly responseService: ResponseService; - - @inject(NotificationServiceServer) - protected readonly notificationService: NotificationServiceServer; + private readonly responseService: ResponseService; @inject(MonitorManager) - protected readonly monitorManager: MonitorManager; - - protected uploading = false; + private readonly monitorManager: MonitorManager; async compile( options: CoreService.Compile.Options & { @@ -47,254 +48,298 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { compilerWarnings?: CompilerWarnings; } ): Promise { - const { sketchUri, board, compilerWarnings } = options; - const sketchPath = FileUri.fsPath(sketchUri); - - await this.coreClientProvider.initialized; const coreClient = await this.coreClient(); const { client, instance } = coreClient; + const handler = this.createOnDataHandler(); + const request = this.compileRequest(options, instance); + return new Promise((resolve, reject) => { + client + .compile(request) + .on('data', handler.onData) + .on('error', (error) => { + if (!ServiceError.is(error)) { + console.error( + 'Unexpected error occurred while compiling the sketch.', + error + ); + reject(error); + } else { + const compilerErrors = tryParseError({ + content: handler.stderr, + sketch: options.sketch, + }); + const message = nls.localize( + 'arduino/compile/error', + 'Compilation error: {0}', + compilerErrors + .map(({ message }) => message) + .filter(notEmpty) + .shift() ?? error.details + ); + this.sendResponse( + error.details + '\n\n' + message, + OutputMessage.Severity.Error + ); + reject(CoreError.VerifyFailed(message, compilerErrors)); + } + }) + .on('end', resolve); + }).finally(() => handler.dispose()); + } - const compileReq = new CompileRequest(); - compileReq.setInstance(instance); - compileReq.setSketchPath(sketchPath); + private compileRequest( + options: CoreService.Compile.Options & { + exportBinaries?: boolean; + compilerWarnings?: CompilerWarnings; + }, + instance: Instance + ): CompileRequest { + const { sketch, board, compilerWarnings } = options; + const sketchUri = sketch.uri; + const sketchPath = FileUri.fsPath(sketchUri); + const request = new CompileRequest(); + request.setInstance(instance); + request.setSketchPath(sketchPath); if (board?.fqbn) { - compileReq.setFqbn(board.fqbn); + request.setFqbn(board.fqbn); } if (compilerWarnings) { - compileReq.setWarnings(compilerWarnings.toLowerCase()); + request.setWarnings(compilerWarnings.toLowerCase()); } - compileReq.setOptimizeForDebug(options.optimizeForDebug); - compileReq.setPreprocess(false); - compileReq.setVerbose(options.verbose); - compileReq.setQuiet(false); + request.setOptimizeForDebug(options.optimizeForDebug); + request.setPreprocess(false); + request.setVerbose(options.verbose); + request.setQuiet(false); if (typeof options.exportBinaries === 'boolean') { const exportBinaries = new BoolValue(); exportBinaries.setValue(options.exportBinaries); - compileReq.setExportBinaries(exportBinaries); - } - this.mergeSourceOverrides(compileReq, options); - - const result = client.compile(compileReq); - - const compileBuffer = new SimpleBuffer( - this.flushOutputPanelMessages.bind(this), - FLUSH_OUTPUT_MESSAGES_TIMEOUT_MS - ); - try { - await new Promise((resolve, reject) => { - result.on('data', (cr: CompileResponse) => { - compileBuffer.addChunk(cr.getOutStream_asU8()); - compileBuffer.addChunk(cr.getErrStream_asU8()); - }); - result.on('error', (error) => { - compileBuffer.clearFlushInterval(); - reject(error); - }); - result.on('end', () => { - compileBuffer.clearFlushInterval(); - resolve(); - }); - }); - this.responseService.appendToOutput({ - chunk: '\n--------------------------\nCompilation complete.\n', - }); - } catch (e) { - const errorMessage = nls.localize( - 'arduino/compile/error', - 'Compilation error: {0}', - e.details - ); - this.responseService.appendToOutput({ - chunk: `${errorMessage}\n`, - severity: 'error', - }); - throw new Error(errorMessage); + request.setExportBinaries(exportBinaries); } + this.mergeSourceOverrides(request, options); + return request; } async upload(options: CoreService.Upload.Options): Promise { - await this.doUpload( + return this.doUpload( options, () => new UploadRequest(), - (client, req) => client.upload(req) + (client, req) => client.upload(req), + (message: string, info: CoreError.ErrorInfo[]) => + CoreError.UploadFailed(message, info), + 'upload' ); } async uploadUsingProgrammer( options: CoreService.Upload.Options ): Promise { - await this.doUpload( + return this.doUpload( options, () => new UploadUsingProgrammerRequest(), (client, req) => client.uploadUsingProgrammer(req), + (message: string, info: CoreError.ErrorInfo[]) => + CoreError.UploadUsingProgrammerFailed(message, info), 'upload using programmer' ); } - isUploading(): Promise { - return Promise.resolve(this.uploading); - } - protected async doUpload( options: CoreService.Upload.Options, - requestProvider: () => UploadRequest | UploadUsingProgrammerRequest, - // tslint:disable-next-line:max-line-length + requestFactory: () => UploadRequest | UploadUsingProgrammerRequest, responseHandler: ( client: ArduinoCoreServiceClient, - req: UploadRequest | UploadUsingProgrammerRequest + request: UploadRequest | UploadUsingProgrammerRequest ) => ClientReadableStream, - task = 'upload' + errorHandler: ( + message: string, + info: CoreError.ErrorInfo[] + ) => ApplicationError, + task: string ): Promise { await this.compile(Object.assign(options, { exportBinaries: false })); - this.uploading = true; - const { sketchUri, board, port, programmer } = options; - await this.monitorManager.notifyUploadStarted(board, port); - - const sketchPath = FileUri.fsPath(sketchUri); - - await this.coreClientProvider.initialized; const coreClient = await this.coreClient(); const { client, instance } = coreClient; + const request = this.uploadOrUploadUsingProgrammerRequest( + options, + instance, + requestFactory + ); + const handler = this.createOnDataHandler(); + return this.notifyUploadWillStart(options).then(() => + new Promise((resolve, reject) => { + responseHandler(client, request) + .on('data', handler.onData) + .on('error', (error) => { + if (!ServiceError.is(error)) { + console.error(`Unexpected error occurred while ${task}.`, error); + reject(error); + } else { + const message = nls.localize( + 'arduino/upload/error', + '{0} error: {1}', + firstToUpperCase(task), + error.details + ); + this.sendResponse(error.details, OutputMessage.Severity.Error); + reject( + errorHandler( + message, + tryParseError({ + content: handler.stderr, + sketch: options.sketch, + }) + ) + ); + } + }) + .on('end', resolve); + }).finally(async () => { + handler.dispose(); + await this.notifyUploadDidFinish(options); + }) + ); + } - const req = requestProvider(); - req.setInstance(instance); - req.setSketchPath(sketchPath); + private uploadOrUploadUsingProgrammerRequest( + options: CoreService.Upload.Options, + instance: Instance, + requestFactory: () => UploadRequest | UploadUsingProgrammerRequest + ): UploadRequest | UploadUsingProgrammerRequest { + const { sketch, board, port, programmer } = options; + const sketchPath = FileUri.fsPath(sketch.uri); + const request = requestFactory(); + request.setInstance(instance); + request.setSketchPath(sketchPath); if (board?.fqbn) { - req.setFqbn(board.fqbn); + request.setFqbn(board.fqbn); } - const p = new Port(); - if (port) { - p.setAddress(port.address); - p.setLabel(port.addressLabel); - p.setProtocol(port.protocol); - p.setProtocolLabel(port.protocolLabel); - } - req.setPort(p); + request.setPort(this.createPort(port)); if (programmer) { - req.setProgrammer(programmer.id); + request.setProgrammer(programmer.id); } - req.setVerbose(options.verbose); - req.setVerify(options.verify); + request.setVerbose(options.verbose); + request.setVerify(options.verify); options.userFields.forEach((e) => { - req.getUserFieldsMap().set(e.name, e.value); + request.getUserFieldsMap().set(e.name, e.value); }); - - const result = responseHandler(client, req); - - const uploadBuffer = new SimpleBuffer( - this.flushOutputPanelMessages.bind(this), - FLUSH_OUTPUT_MESSAGES_TIMEOUT_MS - ); - try { - await new Promise((resolve, reject) => { - result.on('data', (resp: UploadResponse) => { - uploadBuffer.addChunk(resp.getOutStream_asU8()); - uploadBuffer.addChunk(resp.getErrStream_asU8()); - }); - result.on('error', (error) => { - uploadBuffer.clearFlushInterval(); - reject(error); - }); - result.on('end', () => { - uploadBuffer.clearFlushInterval(); - resolve(); - }); - }); - this.responseService.appendToOutput({ - chunk: - '\n--------------------------\n' + - firstToLowerCase(task) + - ' complete.\n', - }); - } catch (e) { - const errorMessage = nls.localize( - 'arduino/upload/error', - '{0} error: {1}', - firstToUpperCase(task), - e.details - ); - this.responseService.appendToOutput({ - chunk: `${errorMessage}\n`, - severity: 'error', - }); - throw new Error(errorMessage); - } finally { - this.uploading = false; - this.monitorManager.notifyUploadFinished(board, port); - } + return request; } async burnBootloader(options: CoreService.Bootloader.Options): Promise { - this.uploading = true; - const { board, port, programmer } = options; - await this.monitorManager.notifyUploadStarted(board, port); - - await this.coreClientProvider.initialized; const coreClient = await this.coreClient(); const { client, instance } = coreClient; - const burnReq = new BurnBootloaderRequest(); - burnReq.setInstance(instance); + const handler = this.createOnDataHandler(); + const request = this.burnBootloaderRequest(options, instance); + return this.notifyUploadWillStart(options).then(() => + new Promise((resolve, reject) => { + client + .burnBootloader(request) + .on('data', handler.onData) + .on('error', (error) => { + if (!ServiceError.is(error)) { + console.error( + 'Unexpected error occurred while burning the bootloader.', + error + ); + reject(error); + } else { + this.sendResponse(error.details, OutputMessage.Severity.Error); + reject( + CoreError.BurnBootloaderFailed( + nls.localize( + 'arduino/burnBootloader/error', + 'Error while burning the bootloader: {0}', + error.details + ), + tryParseError({ content: handler.stderr }) + ) + ); + } + }) + .on('end', resolve); + }).finally(async () => { + handler.dispose(); + await this.notifyUploadDidFinish(options); + }) + ); + } + + private burnBootloaderRequest( + options: CoreService.Bootloader.Options, + instance: Instance + ): BurnBootloaderRequest { + const { board, port, programmer } = options; + const request = new BurnBootloaderRequest(); + request.setInstance(instance); if (board?.fqbn) { - burnReq.setFqbn(board.fqbn); - } - const p = new Port(); - if (port) { - p.setAddress(port.address); - p.setLabel(port.addressLabel); - p.setProtocol(port.protocol); - p.setProtocolLabel(port.protocolLabel); + request.setFqbn(board.fqbn); } - burnReq.setPort(p); + request.setPort(this.createPort(port)); if (programmer) { - burnReq.setProgrammer(programmer.id); + request.setProgrammer(programmer.id); } - burnReq.setVerify(options.verify); - burnReq.setVerbose(options.verbose); - const result = client.burnBootloader(burnReq); + request.setVerify(options.verify); + request.setVerbose(options.verbose); + return request; + } - const bootloaderBuffer = new SimpleBuffer( - this.flushOutputPanelMessages.bind(this), - FLUSH_OUTPUT_MESSAGES_TIMEOUT_MS - ); - try { - await new Promise((resolve, reject) => { - result.on('data', (resp: BurnBootloaderResponse) => { - bootloaderBuffer.addChunk(resp.getOutStream_asU8()); - bootloaderBuffer.addChunk(resp.getErrStream_asU8()); - }); - result.on('error', (error) => { - bootloaderBuffer.clearFlushInterval(); - reject(error); - }); - result.on('end', () => { - bootloaderBuffer.clearFlushInterval(); - resolve(); - }); - }); - } catch (e) { - const errorMessage = nls.localize( - 'arduino/burnBootloader/error', - 'Error while burning the bootloader: {0}', - e.details - ); - this.responseService.appendToOutput({ - chunk: `${errorMessage}\n`, - severity: 'error', + private createOnDataHandler(): Disposable & { + stderr: Buffer[]; + onData: (response: R) => void; + } { + const stderr: Buffer[] = []; + const buffer = new SimpleBuffer((chunks) => { + Array.from(chunks.entries()).forEach(([severity, chunk]) => { + if (chunk) { + this.sendResponse(chunk, severity); + } }); - throw new Error(errorMessage); - } finally { - this.uploading = false; - await this.monitorManager.notifyUploadFinished(board, port); - } + }); + const onData = StreamingResponse.createOnDataHandler(stderr, (out, err) => { + buffer.addChunk(out); + buffer.addChunk(err, OutputMessage.Severity.Error); + }); + return { + dispose: () => buffer.dispose(), + stderr, + onData, + }; + } + + private sendResponse( + chunk: string, + severity: OutputMessage.Severity = OutputMessage.Severity.Info + ): void { + this.responseService.appendToOutput({ chunk, severity }); + } + + private async notifyUploadWillStart({ + board, + port, + }: { + board?: Board | undefined; + port?: Port | undefined; + }): Promise { + return this.monitorManager.notifyUploadStarted(board, port); + } + + private async notifyUploadDidFinish({ + board, + port, + }: { + board?: Board | undefined; + port?: Port | undefined; + }): Promise { + return this.monitorManager.notifyUploadFinished(board, port); } private mergeSourceOverrides( req: { getSourceOverrideMap(): jspb.Map }, options: CoreService.Compile.Options ): void { - const sketchPath = FileUri.fsPath(options.sketchUri); + const sketchPath = FileUri.fsPath(options.sketch.uri); for (const uri of Object.keys(options.sourceOverride)) { const content = options.sourceOverride[uri]; if (content) { @@ -304,9 +349,33 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { } } - private flushOutputPanelMessages(chunk: string): void { - this.responseService.appendToOutput({ - chunk, - }); + private createPort(port: Port | undefined): GrpcPort { + const grpcPort = new GrpcPort(); + if (port) { + grpcPort.setAddress(port.address); + grpcPort.setLabel(port.addressLabel); + grpcPort.setProtocol(port.protocol); + grpcPort.setProtocolLabel(port.protocolLabel); + } + return grpcPort; + } +} +type StreamingResponse = + | CompileResponse + | UploadResponse + | UploadUsingProgrammerResponse + | BurnBootloaderResponse; +namespace StreamingResponse { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + export function createOnDataHandler( + stderr: Uint8Array[], + onData: (out: Uint8Array, err: Uint8Array) => void + ): (response: R) => void { + return (response: R) => { + const out = response.getOutStream_asU8(); + const err = response.getErrStream_asU8(); + stderr.push(err); + onData(out, err); + }; } } diff --git a/arduino-ide-extension/src/node/examples-service-impl.ts b/arduino-ide-extension/src/node/examples-service-impl.ts index 0a056d0a7..0028791a6 100644 --- a/arduino-ide-extension/src/node/examples-service-impl.ts +++ b/arduino-ide-extension/src/node/examples-service-impl.ts @@ -3,23 +3,19 @@ import { injectable, postConstruct, } from '@theia/core/shared/inversify'; -import { join, basename } from 'path'; +import { join } from 'path'; import * as fs from 'fs'; -import { promisify } from 'util'; import { FileUri } from '@theia/core/lib/node/file-uri'; import { - Sketch, SketchRef, SketchContainer, } from '../common/protocol/sketches-service'; -import { SketchesServiceImpl } from './sketches-service-impl'; import { ExamplesService } from '../common/protocol/examples-service'; import { LibraryLocation, LibraryPackage, LibraryService, } from '../common/protocol'; -import { ConfigServiceImpl } from './config-service-impl'; import { duration } from '../common/decorators'; import { URI } from '@theia/core/lib/common/uri'; import { Path } from '@theia/core/lib/common/path'; @@ -88,14 +84,8 @@ export class BuiltInExamplesServiceImpl { @injectable() export class ExamplesServiceImpl implements ExamplesService { - @inject(SketchesServiceImpl) - protected readonly sketchesService: SketchesServiceImpl; - @inject(LibraryService) - protected readonly libraryService: LibraryService; - - @inject(ConfigServiceImpl) - protected readonly configService: ConfigServiceImpl; + private readonly libraryService: LibraryService; @inject(BuiltInExamplesServiceImpl) private readonly builtInExamplesService: BuiltInExamplesServiceImpl; @@ -117,7 +107,7 @@ export class ExamplesServiceImpl implements ExamplesService { fqbn, }); for (const pkg of packages) { - const container = await this.tryGroupExamplesNew(pkg); + const container = await this.tryGroupExamples(pkg); const { location } = pkg; if (location === LibraryLocation.USER) { user.push(container); @@ -130,9 +120,6 @@ export class ExamplesServiceImpl implements ExamplesService { any.push(container); } } - // user.sort((left, right) => left.label.localeCompare(right.label)); - // current.sort((left, right) => left.label.localeCompare(right.label)); - // any.sort((left, right) => left.label.localeCompare(right.label)); return { user, current, any }; } @@ -141,7 +128,7 @@ export class ExamplesServiceImpl implements ExamplesService { * folder hierarchy. This method tries to workaround it by falling back to the `installDirUri` and manually creating the * location of the examples. Otherwise it creates the example container from the direct examples FS paths. */ - protected async tryGroupExamplesNew({ + private async tryGroupExamples({ label, exampleUris, installDirUri, @@ -208,10 +195,6 @@ export class ExamplesServiceImpl implements ExamplesService { if (!child) { child = SketchContainer.create(label); parent.children.push(child); - //TODO: remove or move sort - parent.children.sort((left, right) => - left.label.localeCompare(right.label) - ); } return child; }; @@ -230,65 +213,7 @@ export class ExamplesServiceImpl implements ExamplesService { container ); refContainer.sketches.push(ref); - //TODO: remove or move sort - refContainer.sketches.sort((left, right) => - left.name.localeCompare(right.name) - ); } return container; } - - // Built-ins are included inside the IDE. - protected async load(path: string): Promise { - if (!(await promisify(fs.exists)(path))) { - throw new Error('Examples are not available'); - } - const stat = await promisify(fs.stat)(path); - if (!stat.isDirectory) { - throw new Error(`${path} is not a directory.`); - } - const names = await promisify(fs.readdir)(path); - const sketches: SketchRef[] = []; - const children: SketchContainer[] = []; - for (const p of names.map((name) => join(path, name))) { - const stat = await promisify(fs.stat)(p); - if (stat.isDirectory()) { - const sketch = await this.tryLoadSketch(p); - if (sketch) { - sketches.push({ name: sketch.name, uri: sketch.uri }); - sketches.sort((left, right) => left.name.localeCompare(right.name)); - } else { - const child = await this.load(p); - children.push(child); - children.sort((left, right) => left.label.localeCompare(right.label)); - } - } - } - const label = basename(path); - return { - label, - children, - sketches, - }; - } - - protected async group(paths: string[]): Promise> { - const map = new Map(); - for (const path of paths) { - const stat = await promisify(fs.stat)(path); - map.set(path, stat); - } - return map; - } - - protected async tryLoadSketch(path: string): Promise { - try { - const sketch = await this.sketchesService.loadSketch( - FileUri.create(path).toString() - ); - return sketch; - } catch { - return undefined; - } - } } diff --git a/arduino-ide-extension/src/node/service-error.ts b/arduino-ide-extension/src/node/service-error.ts new file mode 100644 index 000000000..3abbbc0b0 --- /dev/null +++ b/arduino-ide-extension/src/node/service-error.ts @@ -0,0 +1,23 @@ +import { Metadata, StatusObject } from '@grpc/grpc-js'; + +export type ServiceError = StatusObject & Error; +export namespace ServiceError { + export function is(arg: unknown): arg is ServiceError { + return arg instanceof Error && isStatusObjet(arg); + } + function isStatusObjet(arg: unknown): arg is StatusObject { + if (typeof arg === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const any = arg as any; + return ( + !!arg && + 'code' in arg && + 'details' in arg && + typeof any.details === 'string' && + 'metadata' in arg && + any.metadata instanceof Metadata + ); + } + return false; + } +} diff --git a/arduino-ide-extension/src/node/utils/simple-buffer.ts b/arduino-ide-extension/src/node/utils/simple-buffer.ts index 3f5acd506..f05fed2b3 100644 --- a/arduino-ide-extension/src/node/utils/simple-buffer.ts +++ b/arduino-ide-extension/src/node/utils/simple-buffer.ts @@ -1,31 +1,69 @@ -export class SimpleBuffer { - private chunks: Uint8Array[] = []; +import { Disposable } from '@theia/core/shared/vscode-languageserver-protocol'; +import { OutputMessage } from '../../common/protocol'; +const DEFAULT_FLUS_TIMEOUT_MS = 32; + +export class SimpleBuffer implements Disposable { + private readonly chunks = Chunks.create(); private flushInterval?: NodeJS.Timeout; - constructor(onFlush: (chunk: string) => void, flushTimeout: number) { + constructor( + onFlush: (chunks: Map) => void, + flushTimeout: number = DEFAULT_FLUS_TIMEOUT_MS + ) { this.flushInterval = setInterval(() => { - if (this.chunks.length > 0) { - const chunkString = Buffer.concat(this.chunks).toString(); + if (!Chunks.isEmpty(this.chunks)) { + const chunks = Chunks.toString(this.chunks); this.clearChunks(); - - onFlush(chunkString); + onFlush(chunks); } }, flushTimeout); } - public addChunk(chunk: Uint8Array): void { - this.chunks.push(chunk); + public addChunk( + chunk: Uint8Array, + severity: OutputMessage.Severity = OutputMessage.Severity.Info + ): void { + this.chunks.get(severity)?.push(chunk); } private clearChunks(): void { - this.chunks = []; + Chunks.clear(this.chunks); } - public clearFlushInterval(): void { - this.clearChunks(); - + dispose(): void { clearInterval(this.flushInterval); + this.clearChunks(); this.flushInterval = undefined; } } + +type Chunks = Map; +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; + } + return chunks; + } + export function isEmpty(chunks: Chunks): boolean { + return ![...chunks.values()].some((chunk) => Boolean(chunk.length)); + } + export function toString( + chunks: Chunks + ): Map { + return new Map( + Array.from(chunks.entries()).map(([severity, buffers]) => [ + severity, + buffers.length ? Buffer.concat(buffers).toString() : undefined, + ]) + ); + } +} diff --git a/i18n/en.json b/i18n/en.json index 08cafdf72..a61456b53 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -56,6 +56,24 @@ "uploadRootCertificates": "Upload SSL Root Certificates", "uploadingCertificates": "Uploading certificates." }, + "cli-error-parser": { + "byteError": "The 'BYTE' keyword is no longer supported.", + "byteMessage": "As of Arduino 1.0, the 'BYTE' keyword is no longer supported.\nPlease use Serial.write() instead.", + "clientError": "The Client class has been renamed EthernetClient.", + "clientMessage": "As of Arduino 1.0, the Client class in the Ethernet library has been renamed to EthernetClient.", + "keyboardError": "'Keyboard' not found. Does your sketch include the line '#include '?", + "mouseError": "'Mouse' not found. Does your sketch include the line '#include '?", + "receiveError": "Wire.receive() has been renamed Wire.read().", + "receiveMessage": "As of Arduino 1.0, the Wire.receive() function was renamed to Wire.read() for consistency with other libraries.", + "sendError": "Wire.send() has been renamed Wire.write().", + "sendMessage": "As of Arduino 1.0, the Wire.send() function was renamed to Wire.write() for consistency with other libraries.", + "serverError": "The Server class has been renamed EthernetServer.", + "serverMessage": "As of Arduino 1.0, the Server class in the Ethernet library has been renamed to EthernetServer.", + "spiError": "Please import the SPI library from the Sketch > Import Library menu.", + "spiMessage": "As of Arduino 0019, the Ethernet library depends on the SPI library.\nYou appear to be using it or another library that depends on the SPI library.", + "udpError": "The Udp class has been renamed EthernetUdp.", + "udpMessage": "As of Arduino 1.0, the Udp class in the Ethernet library has been renamed to EthernetUdp." + }, "cloud": { "chooseSketchVisibility": "Choose visibility of your Sketch:", "cloudSketchbook": "Cloud Sketchbook", @@ -120,6 +138,9 @@ "fileAdded": "One file added to the sketch.", "replaceTitle": "Replace" }, + "coreContribution": { + "copyError": "Copy error messages" + }, "debug": { "debugWithMessage": "Debug - {0}", "debuggingNotSupported": "Debugging is not supported by '{0}'", @@ -136,7 +157,9 @@ "decreaseFontSize": "Decrease Font Size", "decreaseIndent": "Decrease Indent", "increaseFontSize": "Increase Font Size", - "increaseIndent": "Increase Indent" + "increaseIndent": "Increase Indent", + "nextError": "Next Error", + "previousError": "Previous Error" }, "electron": { "couldNotSave": "Could not save the sketch. Please copy your unsaved work into your favorite text editor, and restart the IDE.", @@ -235,6 +258,8 @@ "cloud.pushpublic.warn": "True if users should be warned before pushing a public sketch to the cloud. Defaults to true.", "cloud.sketchSyncEnpoint": "The endpoint used to push and pull sketches from a backend. By default it points to Arduino Cloud API.", "compile": "compile", + "compile.experimental": "True if the IDE should handle multiple compiler errors. False by default", + "compile.revealRange": "Adjusts how compiler errors are revealed in the editor after a failed verify/upload. Possible values: 'auto': Scroll vertically as necessary and reveal a line. 'center': Scroll vertically as necessary and reveal a line centered vertically. 'top': Scroll vertically as necessary and reveal a line close to the top of the viewport, optimized for viewing a code definition. 'centerIfOutsideViewport': Scroll vertically as necessary and reveal a line centered vertically only if it lies outside the viewport. The default value is '{0}'.", "compile.verbose": "True for verbose compile output. False by default", "compile.warnings": "Tells gcc which warning level to use. It's 'None' by default", "compilerWarnings": "Compiler warnings",