@jupyter-lsp/jupyterlab-lsp
Version:
Language Server Protocol integration for JupyterLab
624 lines (563 loc) • 21.3 kB
text/typescript
import { linter, Diagnostic, lintGutter } from '@codemirror/lint';
import { StateField, StateEffect, StateEffectType } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { INotebookShell } from '@jupyter-notebook/application';
import { ILabShell } from '@jupyterlab/application';
import { IThemeManager } from '@jupyterlab/apputils';
import {
CodeMirrorEditor,
IEditorExtensionRegistry,
EditorExtensionRegistry
} from '@jupyterlab/codemirror';
import {
WidgetLSPAdapter,
IEditorPosition,
IVirtualPosition,
ILSPConnection,
VirtualDocument
} from '@jupyterlab/lsp';
import { TranslationBundle } from '@jupyterlab/translation';
import { PromiseDelegate } from '@lumino/coreutils';
import { StyleModule } from 'style-mod';
import * as lsProtocol from 'vscode-languageserver-protocol';
import { CodeDiagnostics as LSPDiagnosticsSettings } from '../../_diagnostics';
import { PositionConverter } from '../../converter';
import { IFeatureSettings, Feature } from '../../feature';
import { DiagnosticSeverity, DiagnosticTag } from '../../lsp';
import { PLUGIN_ID } from '../../tokens';
import { urisEqual } from '../../utils';
import { BrowserConsole } from '../../virtual/console';
import { diagnosticsPanel } from './diagnostics';
import { DiagnosticsDatabase } from './listing';
import { IDiagnosticsFeature, IEditorDiagnostic } from './tokens';
import { underline } from './underline';
const SeverityMap: Record<
1 | 2 | 3 | 4,
'error' | 'warning' | 'info' | 'hint'
> = {
1: 'error',
2: 'warning',
3: 'info',
4: 'hint'
};
export class DiagnosticsFeature extends Feature implements IDiagnosticsFeature {
readonly id = DiagnosticsFeature.id;
readonly capabilities: lsProtocol.ClientCapabilities = {
textDocument: {
publishDiagnostics: {
tagSupport: {
valueSet: [DiagnosticTag.Deprecated, DiagnosticTag.Unnecessary]
}
}
}
};
protected settings: IFeatureSettings<LSPDiagnosticsSettings>;
protected console = new BrowserConsole().scope('Diagnostics');
private _firstResponseReceived: PromiseDelegate<void> = new PromiseDelegate();
private _diagnosticsDatabases = new WeakMap<
WidgetLSPAdapter<any>,
DiagnosticsDatabase
>();
constructor(options: DiagnosticsFeature.IOptions) {
super(options);
this.settings = options.settings;
options.connectionManager.connected.connect((manager, connectionData) => {
const { connection, virtualDocument } = connectionData;
const adapter = manager.adapters.get(virtualDocument.root.path)!;
// TODO: unregister
connection.serverNotifications['textDocument/publishDiagnostics'].connect(
async (connection: ILSPConnection, diagnostics) => {
await this.handleDiagnostic(diagnostics, virtualDocument, adapter);
}
);
virtualDocument.foreignDocumentClosed.connect((document, context) => {
// TODO: check if we need to cast
this.clearDocumentDiagnostics(adapter, context.foreignDocument);
});
});
//this.unique_editor_ids = new DefaultMap(() => this.unique_editor_ids.size);
this.settings.changed.connect(this.refreshDiagnostics, this);
this._trans = options.trans;
this._invalidate = StateEffect.define<void>();
this._invalidationCounter = StateField.define<number>({
create: () => 0,
update: (value, tr) => {
for (const e of tr.effects) {
if (e.is(this._invalidate)) {
value += 1;
}
}
return value;
}
});
const connectionManager = options.connectionManager;
// https://github.com/jupyterlab/jupyterlab/issues/14783
options.shell.currentChanged.connect(shell => {
if (shell.currentWidget == diagnosticsPanel.widget) {
// allow focusing on the panel
return;
}
const adapter = [...connectionManager.adapters.values()].find(
adapter => adapter.widget == shell.currentWidget
);
if (!adapter) {
this.switchDiagnosticsPanelSource(null);
// this dance should not be needed once https://github.com/jupyterlab/jupyterlab/pull/14920 is in,
// but we will need to continue listening to `currentChanged` signal anyways to make sure we show
// empty indicator in launcher or other widget which does not support linting.
let attemptsLeft = 3;
const retry = () => {
const adapter = [...connectionManager.adapters.values()].find(
adapter => adapter.widget == shell.currentWidget
);
attemptsLeft -= 1;
if (adapter) {
this.switchDiagnosticsPanelSource(adapter);
attemptsLeft = 0;
}
if (attemptsLeft == 0) {
connectionManager.connected.disconnect(retry);
}
};
connectionManager.connected.connect(retry);
this.console.debug(
'No adapter (yet?), will retry on next connected document'
);
} else {
this.switchDiagnosticsPanelSource(adapter);
}
});
const settings = options.settings;
const themeManager = options.themeManager;
this._reconfigureTheme();
document.head.appendChild(this._styleElement);
if (themeManager) {
themeManager.themeChanged.connect(() => {
this._reconfigureTheme();
});
}
this.extensionFactory = {
name: 'lsp:diagnostics',
factory: factoryOptions => {
const { widgetAdapter: adapter } = factoryOptions;
const source = async (view: EditorView) => {
let diagnostics: Diagnostic[] = [];
// NHT: `response.version` could be checked against document versions
// and if non matches we could yield (raise an error or hang for a
// few seconds to trigger timeout). Because `response.version` is
// optional it would require further testing.
if (view.state.field(this._invalidationCounter) == 0) {
// If we are displaying the editor for the first time,
// e.g. after scrolling down in windowed notebook,
// do not wait for next update, show what we already know.
// TODO: this still fails when scrolling down fast and then
// scrolling up to the skipped cells because the state invalidation
// counter kicks in but diagnostics does not get rendered yet before
// we leave..
await this._firstResponseReceived.promise;
} else {
await adapter.updateFinished;
}
const database = this.getDiagnosticsDB(adapter);
for (const editorDiagnostics of database.values()) {
for (const editorDiagnostic of editorDiagnostics) {
const editor = editorDiagnostic.editorAccessor.getEditor() as
| CodeMirrorEditor
| undefined;
if (editor?.editor !== view) {
continue;
}
const diagnostic = editorDiagnostic.diagnostic;
const severity = SeverityMap[diagnostic.severity!];
const lines = view.state.doc.lines;
const lastLineLength = view.state.doc.line(lines).length;
const start = PositionConverter.cm_to_ce(
editorDiagnostic.range.start
);
const end = PositionConverter.cm_to_ce(
editorDiagnostic.range.end
);
const from = editor.getOffsetAt(
start.line >= lines
? {
line: Math.min(start.line, lines),
column: Math.min(start.column, lastLineLength)
}
: start
);
// TODO: this is wrong; there is however an issue if this is not applied
const to = editor.getOffsetAt(
end.line >= lines
? {
line: Math.min(end.line, lines),
column: Math.min(end.column, lastLineLength)
}
: end
);
const classNames = [];
for (const tag of new Set(diagnostic.tags)) {
classNames.push('cm-lsp-diagnostic-tag-' + DiagnosticTag[tag]);
}
diagnostics.push({
from: Math.min(from, view.state.doc.length),
to: Math.min(to, view.state.doc.length),
severity: severity,
message: diagnostic.message,
source: diagnostic.source,
markClass: classNames.join(' ')
// TODO: actions
});
}
}
return diagnostics;
};
// never run linter on typing - we will trigger it manually when update is needed
const lspLinter = linter(source, {
delay: settings.composite.debounceDelay || 250,
needsRefresh: update => {
const previous = update.startState.field(this._invalidationCounter);
const current = update.state.field(this._invalidationCounter);
return previous !== current;
}
});
const extensions = [lspLinter, this._invalidationCounter];
if (settings.composite.gutter) {
extensions.push(lintGutter());
}
return EditorExtensionRegistry.createImmutableExtension(extensions);
}
};
}
private _reconfigureTheme() {
const style = getComputedStyle(document.body);
const lintTheme = new StyleModule({
'.cm-editor .cm-lintRange-error': {
backgroundImage: underline(
style.getPropertyValue(
'--jp-editor-mirror-lsp-diagnostic-error-decoration-color'
)
)
},
'.cm-editor .cm-lintRange-warning': {
backgroundImage: underline(
style.getPropertyValue(
'--jp-editor-mirror-lsp-diagnostic-warning-decoration-color'
)
)
},
'.cm-editor .cm-lintRange-info': {
backgroundImage: underline(
style.getPropertyValue(
'--jp-editor-mirror-lsp-diagnostic-information-decoration-color'
)
)
},
'.cm-editor .cm-lintRange-hint': {
backgroundImage: underline(
style.getPropertyValue(
'--jp-editor-mirror-lsp-diagnostic-hint-decoration-color'
)
)
}
});
this._styleElement.innerHTML = lintTheme.getRules();
}
clearDocumentDiagnostics(
adapter: WidgetLSPAdapter<any>,
document: VirtualDocument
) {
this.getDiagnosticsDB(adapter).set(document, []);
}
/**
* Allows access to the most recent diagnostics in context of the editor.
*
* One can use VirtualEditorForNotebook.find_cell_by_editor() to find
* the corresponding cell in notebook.
* Can be used to implement a Panel showing diagnostics list.
*
* Maps virtualDocument.uri to IEditorDiagnostic[].
*/
public getDiagnosticsDB(adapter: WidgetLSPAdapter<any>): DiagnosticsDatabase {
// Note that virtual_editor can change at runtime (kernel restart)
if (!this._diagnosticsDatabases.has(adapter)) {
this._diagnosticsDatabases.set(adapter, new DiagnosticsDatabase());
}
return this._diagnosticsDatabases.get(adapter)!;
}
switchDiagnosticsPanelSource = (adapter: WidgetLSPAdapter<any> | null) => {
diagnosticsPanel.trans = this._trans;
if (adapter !== null) {
const diagnostics = this.getDiagnosticsDB(adapter);
if (diagnosticsPanel.content.model.diagnostics == diagnostics) {
return;
}
diagnosticsPanel.content.model.diagnostics = diagnostics;
diagnosticsPanel.content.model.settings = this.settings;
diagnosticsPanel.feature = this;
} else {
diagnosticsPanel.content.model.diagnostics = null;
}
diagnosticsPanel.content.model.adapter = adapter;
diagnosticsPanel.update();
};
protected diagnosticsByRange(
diagnostics: lsProtocol.Diagnostic[]
): Map<lsProtocol.Range, lsProtocol.Diagnostic[]> {
// because Range is not a primitive type, the equality of the objects having
// the same parameters won't be compared (thus considered equal) in Map.
// instead, a intermediate step of mapping through a stringified representation of Range is needed:
// an alternative would be using nested [start line][start character][end line][end character] structure,
// which would increase the code complexity, but reduce memory use and may be slightly faster.
type RangeID = string;
const rangeIdToRange = new Map<RangeID, lsProtocol.Range>();
const rangeIdToDiagnostics = new Map<RangeID, lsProtocol.Diagnostic[]>();
function getRangeId(range: lsProtocol.Range): RangeID {
return (
range.start.line +
',' +
range.start.character +
',' +
range.end.line +
',' +
range.end.character
);
}
diagnostics.forEach((diagnostic: lsProtocol.Diagnostic) => {
let range = diagnostic.range;
let rangeId = getRangeId(range);
rangeIdToRange.set(rangeId, range);
if (rangeIdToDiagnostics.has(rangeId)) {
let rangesList = rangeIdToDiagnostics.get(rangeId)!;
rangesList.push(diagnostic);
} else {
rangeIdToDiagnostics.set(rangeId, [diagnostic]);
}
});
let map = new Map<lsProtocol.Range, lsProtocol.Diagnostic[]>();
rangeIdToDiagnostics.forEach(
(rangeDiagnostics: lsProtocol.Diagnostic[], rangeId: RangeID) => {
let range = rangeIdToRange.get(rangeId)!;
map.set(range, rangeDiagnostics);
}
);
return map;
}
get defaultSeverity(): lsProtocol.DiagnosticSeverity {
return DiagnosticSeverity[this.settings.composite.defaultSeverity];
}
private filterDiagnostics(
diagnostics: lsProtocol.Diagnostic[]
): lsProtocol.Diagnostic[] {
const ignoredDiagnosticsCodes = new Set(
this.settings.composite.ignoreCodes
);
const ignoredSeverities = new Set<number>(
this.settings.composite.ignoreSeverities.map(
severityName => DiagnosticSeverity[severityName]
)
);
const ignoredMessagesRegExp =
this.settings.composite.ignoreMessagesPatterns.map(
pattern => new RegExp(pattern)
);
return diagnostics.filter(diagnostic => {
let code = diagnostic.code;
if (
typeof code !== 'undefined' &&
// pygls servers return code null if value is missing (rather than undefined)
// which is a departure from the LSP specs: https://microsoft.github.io/language-server-protocol/specification#diagnostic
// there is an open issue: https://github.com/openlawlibrary/pygls/issues/124
// and PR: https://github.com/openlawlibrary/pygls/pull/132
// this also affects hover tooltips.
code !== null &&
ignoredDiagnosticsCodes.has(code.toString())
) {
return false;
}
let severity = diagnostic.severity;
if (severity && ignoredSeverities.has(severity)) {
return false;
}
let message = diagnostic.message;
if (
message &&
ignoredMessagesRegExp.some(pattern => pattern.test(message))
) {
return false;
}
return true;
});
}
setDiagnostics(
response: lsProtocol.PublishDiagnosticsParams,
document: VirtualDocument,
adapter: WidgetLSPAdapter<any>
) {
let diagnosticsList: IEditorDiagnostic[] = [];
// TODO: test case for severity class always being set, even if diagnostic has no severity
let diagnosticsByRange = this.diagnosticsByRange(
this.filterDiagnostics(response.diagnostics)
);
diagnosticsByRange.forEach(
(diagnostics: lsProtocol.Diagnostic[], range: lsProtocol.Range) => {
const start = PositionConverter.lsp_to_cm(
range.start
) as IVirtualPosition;
const end = PositionConverter.lsp_to_cm(range.end) as IVirtualPosition;
const lastLineNumber =
document.lastVirtualLine - document.blankLinesBetweenCells;
if (start.line > lastLineNumber) {
this.console.log(
`Out of range diagnostic (${start.line} line > ${lastLineNumber}) was skipped `,
diagnostics
);
return;
} else {
let lastLine = document.lastLine;
if (start.line == lastLineNumber && start.ch > lastLine.length) {
this.console.log(
`Out of range diagnostic (${start.ch} character > ${lastLine.length} at line ${lastLineNumber}) was skipped `,
diagnostics
);
return;
}
}
if (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore TODO
document.virtualLines
.get(start.line)!
.skipInspect.includes(document.idPath)
) {
this.console.debug(
'Ignoring inspections silenced for this document:',
diagnostics,
document.idPath,
start.line
);
return;
}
const editorAccessor = document.getEditorAtVirtualLine(start);
const startInEditor = document.transformVirtualToEditor(start);
let endInEditor: IEditorPosition | null;
if (startInEditor === null) {
this.console.warn(
'Start in editor could not be be determined for',
diagnostics
);
return;
}
// some servers return strange positions for ends
try {
endInEditor = document.transformVirtualToEditor(end);
} catch (err) {
this.console.warn('Malformed range for diagnostic', end);
endInEditor = { ...startInEditor, ch: startInEditor.ch + 1 };
}
if (endInEditor === null) {
this.console.warn(
'End in editor could not be be determined for',
diagnostics
);
return;
}
for (let diagnostic of diagnostics) {
diagnosticsList.push({
diagnostic,
editorAccessor: editorAccessor,
range: {
start: startInEditor,
end: endInEditor
}
});
}
}
);
const diagnosticsDB = this.getDiagnosticsDB(adapter);
const previousList = diagnosticsDB.get(document);
const editorsWhichHadDiagnostics = new Set(
previousList?.map(d => d.editorAccessor.getEditor())
);
const editorsWithDiagnostics = new Set(
diagnosticsList?.map(d => d.editorAccessor.getEditor())
);
diagnosticsDB.set(document, diagnosticsList);
// Refresh editors with diagnostics; this is needed because linter's
// `source()` method will only refresh the cell with changes, but a change
// in one cell can influence validity of code in all other cells (e.g. due
// to removal of variable definition or usage).
for (const block of adapter.editors) {
const editor = block.ceEditor.getEditor() as CodeMirrorEditor | undefined;
if (!editor) {
continue;
}
if (
!(
editorsWithDiagnostics.has(editor) ||
editorsWhichHadDiagnostics.has(editor)
)
) {
continue;
}
editor.editor.dispatch({
effects: this._invalidate.of()
});
}
}
public handleDiagnostic = async (
response: lsProtocol.PublishDiagnosticsParams,
document: VirtualDocument,
adapter: WidgetLSPAdapter<any>
) => {
// use optional chaining operator because the diagnostics message may come late (after the document was disposed)
if (!urisEqual(response.uri, document?.documentInfo?.uri)) {
return;
}
if (document.lastVirtualLine === 0) {
return;
}
try {
this._lastResponse = response;
this._lastDocument = document;
this._lastAdapter = adapter;
this.setDiagnostics(response, document, adapter);
const done = new Promise<void>(resolve => {
setTimeout(() => {
this._firstResponseReceived.resolve();
resolve();
}, 0);
});
diagnosticsPanel.update();
return done;
} catch (e) {
this.console.warn(e);
}
};
public refreshDiagnostics() {
if (this._lastResponse) {
this.setDiagnostics(
this._lastResponse,
this._lastDocument,
this._lastAdapter
);
}
diagnosticsPanel.update();
}
private _lastResponse: lsProtocol.PublishDiagnosticsParams;
private _lastDocument: VirtualDocument;
private _lastAdapter: WidgetLSPAdapter<any>;
private _trans: TranslationBundle;
private _invalidate: StateEffectType<void>;
private _invalidationCounter: StateField<number>;
private _styleElement: HTMLStyleElement = document.createElement('style');
}
export namespace DiagnosticsFeature {
export interface IOptions extends Feature.IOptions {
settings: IFeatureSettings<LSPDiagnosticsSettings>;
shell: ILabShell | INotebookShell;
trans: TranslationBundle;
editorExtensionRegistry: IEditorExtensionRegistry;
themeManager: IThemeManager | null;
}
export const id = PLUGIN_ID + ':diagnostics';
}