UNPKG

@jupyter-lsp/jupyterlab-lsp

Version:

Language Server Protocol integration for JupyterLab

624 lines (563 loc) 21.3 kB
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'; }