@jupyter-lsp/jupyterlab-lsp
Version:
Language Server Protocol integration for JupyterLab
426 lines • 20.8 kB
JavaScript
import { linter, lintGutter } from '@codemirror/lint';
import { StateField, StateEffect } from '@codemirror/state';
import { EditorExtensionRegistry } from '@jupyterlab/codemirror';
import { PromiseDelegate } from '@lumino/coreutils';
import { StyleModule } from 'style-mod';
import { PositionConverter } from '../../converter';
import { 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 { underline } from './underline';
const SeverityMap = {
1: 'error',
2: 'warning',
3: 'info',
4: 'hint'
};
export class DiagnosticsFeature extends Feature {
constructor(options) {
super(options);
this.id = DiagnosticsFeature.id;
this.capabilities = {
textDocument: {
publishDiagnostics: {
tagSupport: {
valueSet: [DiagnosticTag.Deprecated, DiagnosticTag.Unnecessary]
}
}
}
};
this.console = new BrowserConsole().scope('Diagnostics');
this._firstResponseReceived = new PromiseDelegate();
this._diagnosticsDatabases = new WeakMap();
this.switchDiagnosticsPanelSource = (adapter) => {
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();
};
this.handleDiagnostic = async (response, document, adapter) => {
var _a;
// use optional chaining operator because the diagnostics message may come late (after the document was disposed)
if (!urisEqual(response.uri, (_a = document === null || document === void 0 ? void 0 : document.documentInfo) === null || _a === void 0 ? void 0 : _a.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(resolve => {
setTimeout(() => {
this._firstResponseReceived.resolve();
resolve();
}, 0);
});
diagnosticsPanel.update();
return done;
}
catch (e) {
this.console.warn(e);
}
};
this._styleElement = document.createElement('style');
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, 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();
this._invalidationCounter = StateField.define({
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) => {
let diagnostics = [];
// 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();
if ((editor === null || editor === void 0 ? void 0 : 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);
}
};
}
_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, document) {
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[].
*/
getDiagnosticsDB(adapter) {
// 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);
}
diagnosticsByRange(diagnostics) {
// 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.
const rangeIdToRange = new Map();
const rangeIdToDiagnostics = new Map();
function getRangeId(range) {
return (range.start.line +
',' +
range.start.character +
',' +
range.end.line +
',' +
range.end.character);
}
diagnostics.forEach((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();
rangeIdToDiagnostics.forEach((rangeDiagnostics, rangeId) => {
let range = rangeIdToRange.get(rangeId);
map.set(range, rangeDiagnostics);
});
return map;
}
get defaultSeverity() {
return DiagnosticSeverity[this.settings.composite.defaultSeverity];
}
filterDiagnostics(diagnostics) {
const ignoredDiagnosticsCodes = new Set(this.settings.composite.ignoreCodes);
const ignoredSeverities = new Set(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, document, adapter) {
let diagnosticsList = [];
// 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, range) => {
const start = PositionConverter.lsp_to_cm(range.start);
const end = PositionConverter.lsp_to_cm(range.end);
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;
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 === null || previousList === void 0 ? void 0 : previousList.map(d => d.editorAccessor.getEditor()));
const editorsWithDiagnostics = new Set(diagnosticsList === null || diagnosticsList === void 0 ? void 0 : 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();
if (!editor) {
continue;
}
if (!(editorsWithDiagnostics.has(editor) ||
editorsWhichHadDiagnostics.has(editor))) {
continue;
}
editor.editor.dispatch({
effects: this._invalidate.of()
});
}
}
refreshDiagnostics() {
if (this._lastResponse) {
this.setDiagnostics(this._lastResponse, this._lastDocument, this._lastAdapter);
}
diagnosticsPanel.update();
}
}
(function (DiagnosticsFeature) {
DiagnosticsFeature.id = PLUGIN_ID + ':diagnostics';
})(DiagnosticsFeature || (DiagnosticsFeature = {}));
//# sourceMappingURL=feature.js.map