UNPKG

@jupyter-lsp/jupyterlab-lsp

Version:

Language Server Protocol integration for JupyterLab

426 lines 20.8 kB
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