Link compiler errors to editor.

Closes #118

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
This commit is contained in:
Akos Kitta 2022-07-14 10:41:17 +02:00 committed by Akos Kitta
parent 8b3f3c69fc
commit 5226636fed
9 changed files with 546 additions and 275 deletions

View File

@ -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;
}
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
): 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;
}
return undefined;
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 editorProvider(uri).then((editor) => {
if (editor) {
return rangeOf({ id, uri }, editor);
}
return undefined;
});
return false;
}
// 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
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(
editorOrModel: MonacoEditor | ITextModel | undefined,
decorations: ErrorDecoration
): monaco.Range | undefined;
export function rangeOf(
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 Array.isArray(decorations)
? decorations.map(() => undefined)
: undefined;
}
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,24 +439,13 @@ export class CompilerErrors
]);
const currentError = this.errors[0];
if (currentError) {
await this.markAsCurrentError(currentError);
await this.markAsCurrentError(currentError, {
forceReselect: true,
reveal: true,
});
}
}
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;
}
// Always shows maximum one error; hence the code lens navigation is unavailable.
return [errors[0]];
}
private async decorateEditors(
errors: Map<string, CoreError.ErrorLocation[]>
): Promise<{ dispose: Disposable; errors: ErrorDecoration[] }> {
@ -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',
};
}
}

View File

@ -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 }];
}

View File

@ -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',
};

View File

@ -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);
}

View File

@ -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,

View File

@ -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;
}

View File

@ -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.

View File

@ -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;
}
}

View File

@ -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.",