UNPKG

@krassowski/jupyterlab-lsp

Version:

Language Server Protocol integration for JupyterLab

541 lines 24.1 kB
import { MainAreaWidget } from '@jupyterlab/apputils'; import { nullTranslator } from '@jupyterlab/translation'; import { LabIcon, copyIcon } from '@jupyterlab/ui-components'; import { Menu } from '@lumino/widgets'; import diagnosticsSvg from '../../../style/icons/diagnostics.svg'; import { PositionConverter } from '../../converter'; import { CodeMirrorIntegration } from '../../editor_integration/codemirror'; import { DiagnosticSeverity, DiagnosticTag } from '../../lsp'; import { DefaultMap, uris_equal } from '../../utils'; import { jumpToIcon } from '../jump_to'; import { DIAGNOSTICS_LISTING_CLASS, DiagnosticsDatabase, DiagnosticsListing } from './listing'; export const diagnosticsIcon = new LabIcon({ name: 'lsp:diagnostics', svgstr: diagnosticsSvg }); const CMD_COLUMN_VISIBILITY = 'lsp-set-column-visibility'; const CMD_JUMP_TO_DIAGNOSTIC = 'lsp-jump-to-diagnostic'; const CMD_COPY_DIAGNOSTIC = 'lsp-copy-diagnostic'; const CMD_IGNORE_DIAGNOSTIC_CODE = 'lsp-ignore-diagnostic-code'; const CMD_IGNORE_DIAGNOSTIC_MSG = 'lsp-ignore-diagnostic-message'; /** * Escape pattern to form a base of a regular expression. * The snippet comes from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping * and is in the Public Domain (CC0): * > Any copyright is dedicated to the Public Domain. * > http://creativecommons.org/publicdomain/zero/1.0/ */ function escapeRegExp(string) { return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); } class DiagnosticsPanel { constructor(trans) { this._content = null; this._widget = null; this.is_registered = false; this.trans = trans; } get widget() { if (this._widget == null || this._widget.content.model == null) { if (this._widget && !this._widget.isDisposed) { this._widget.dispose(); } this._widget = this.initWidget(); } return this._widget; } get content() { return this.widget.content; } initWidget() { this._content = new DiagnosticsListing(new DiagnosticsListing.Model(this.trans)); this._content.model.diagnostics = new DiagnosticsDatabase(); this._content.addClass('lsp-diagnostics-panel-content'); const widget = new MainAreaWidget({ content: this._content }); widget.id = 'lsp-diagnostics-panel'; widget.title.label = this.trans.__('Diagnostics Panel'); widget.title.closable = true; widget.title.icon = diagnosticsIcon; return widget; } update() { // if not attached, do not bother to update if (!this.widget.isAttached) { return; } this.widget.content.update(); } register(app) { const widget = this.widget; let get_column = (id) => { // TODO: a hashmap in the panel itself? for (let column of widget.content.columns) { if (column.id === id) { return column; } } return undefined; }; /** Columns Menu **/ let columns_menu = new Menu({ commands: app.commands }); columns_menu.title.label = this.trans.__('Panel columns'); app.commands.addCommand(CMD_COLUMN_VISIBILITY, { execute: args => { let column = get_column(args['id']); column.is_visible = !column.is_visible; widget.update(); }, label: args => this.trans.__(args['id']), isToggled: args => { let column = get_column(args['id']); return column ? column.is_visible : false; } }); for (let column of widget.content.columns) { columns_menu.addItem({ command: CMD_COLUMN_VISIBILITY, args: { id: column.id } }); } app.contextMenu.addItem({ selector: '.' + DIAGNOSTICS_LISTING_CLASS + ' th', submenu: columns_menu, type: 'submenu' }); /** Diagnostics Menu **/ let ignore_diagnostics_menu = new Menu({ commands: app.commands }); ignore_diagnostics_menu.title.label = this.trans.__('Ignore diagnostics like this'); let get_row = () => { let tr = app.contextMenuHitTest(node => node.tagName.toLowerCase() == 'tr'); if (!tr) { return; } return this.widget.content.get_diagnostic(tr.dataset.key); }; ignore_diagnostics_menu.addItem({ command: CMD_IGNORE_DIAGNOSTIC_CODE }); ignore_diagnostics_menu.addItem({ command: CMD_IGNORE_DIAGNOSTIC_MSG }); app.commands.addCommand(CMD_IGNORE_DIAGNOSTIC_CODE, { execute: () => { const row = get_row(); if (!row) { console.warn('LPS: diagnostics row not found for ignore code execute()'); return; } const diagnostic = row.data.diagnostic; let current = this.content.model.settings.composite.ignoreCodes; this.content.model.settings.set('ignoreCodes', [ ...current, diagnostic.code ]); this.feature.refreshDiagnostics(); }, isVisible: () => { const row = get_row(); if (!row) { return false; } const diagnostic = row.data.diagnostic; return !!diagnostic.code; }, label: () => { const row = get_row(); if (!row) { return ''; } const diagnostic = row.data.diagnostic; return this.trans.__('Ignore diagnostics with "%1" code', diagnostic.code); } }); app.commands.addCommand(CMD_IGNORE_DIAGNOSTIC_MSG, { execute: () => { const row = get_row(); if (!row) { console.warn('LPS: diagnostics row not found for ignore message execute()'); return; } const diagnostic = row.data.diagnostic; let current = this.content.model.settings.composite.ignoreMessagesPatterns; this.content.model.settings.set('ignoreMessagesPatterns', [ ...current, escapeRegExp(diagnostic.message) ]); this.feature.refreshDiagnostics(); }, isVisible: () => { const row = get_row(); if (!row) { return false; } const diagnostic = row.data.diagnostic; return !!diagnostic.message; }, label: () => { const row = get_row(); if (!row) { return ''; } const diagnostic = row.data.diagnostic; return this.trans.__('Ignore diagnostics with "%1" message', diagnostic.message); } }); app.commands.addCommand(CMD_JUMP_TO_DIAGNOSTIC, { execute: () => { const row = get_row(); if (!row) { console.warn('LPS: diagnostics row not found for jump execute()'); return; } this.widget.content.jump_to(row); }, label: this.trans.__('Jump to location'), icon: jumpToIcon }); app.commands.addCommand(CMD_COPY_DIAGNOSTIC, { execute: () => { const row = get_row(); if (!row) { console.warn('LPS: diagnostics row not found for copy execute()'); return; } const message = row.data.diagnostic.message; navigator.clipboard .writeText(message) .then(() => { this.content.model.status_message.set(this.trans.__('Successfully copied "%1" to clipboard', message)); }) .catch(() => { console.warn('Could not copy with clipboard.writeText interface, falling back'); window.prompt(this.trans.__('Your browser protects clipboard from write operations; please copy the message manually'), message); }); }, label: this.trans.__("Copy diagnostics' message"), icon: copyIcon }); app.contextMenu.addItem({ selector: '.' + DIAGNOSTICS_LISTING_CLASS + ' tbody tr', command: CMD_COPY_DIAGNOSTIC }); app.contextMenu.addItem({ selector: '.' + DIAGNOSTICS_LISTING_CLASS + ' tbody tr', command: CMD_JUMP_TO_DIAGNOSTIC }); app.contextMenu.addItem({ selector: '.' + DIAGNOSTICS_LISTING_CLASS + ' tbody tr', submenu: ignore_diagnostics_menu, type: 'submenu' }); this.is_registered = true; } } export const diagnostics_panel = new DiagnosticsPanel(nullTranslator.load('jupyterlab_lsp')); export const diagnostics_databases = new WeakMap(); export class DiagnosticsCM extends CodeMirrorIntegration { constructor() { super(...arguments); this.marked_diagnostics = new Map(); this.switchDiagnosticsPanelSource = () => { diagnostics_panel.trans = this.adapter.trans; if (diagnostics_panel.content.model.virtual_editor === this.virtual_editor && diagnostics_panel.content.model.diagnostics == this.diagnostics_db) { return; } diagnostics_panel.content.model.diagnostics = this.diagnostics_db; diagnostics_panel.content.model.virtual_editor = this.virtual_editor; diagnostics_panel.content.model.adapter = this.adapter; diagnostics_panel.content.model.settings = this.settings; diagnostics_panel.content.model.status_message = this.status_message; diagnostics_panel.feature = this; diagnostics_panel.update(); }; this.handleDiagnostic = (connection, response) => { var _a, _b; // use optional chaining operator because the diagnostics message may come late (after the document was disposed) if (!uris_equal(response.uri, (_b = (_a = this.virtual_document) === null || _a === void 0 ? void 0 : _a.document_info) === null || _b === void 0 ? void 0 : _b.uri)) { return; } if (this.virtual_document.last_virtual_line === 0) { return; } /* TODO: gutters */ try { this.last_response = response; this.setDiagnostics(response); diagnostics_panel.update(); } catch (e) { this.console.warn(e); } }; } get settings() { return super.settings; } register() { // this.connection_handlers.set('diagnostic', this.handleDiagnostic); // TODO: unregister this.connection.serverNotifications['textDocument/publishDiagnostics'].connect(this.handleDiagnostic); this.wrapper_handlers.set('focusin', this.switchDiagnosticsPanelSource); this.unique_editor_ids = new DefaultMap(() => this.unique_editor_ids.size); this.settings.changed.connect(this.refreshDiagnostics, this); this.adapter.adapterConnected.connect(() => this.switchDiagnosticsPanelSource()); this.virtual_document.foreign_document_closed.connect((document, context) => { this.clearDocumentDiagnostics(context.foreign_document); }); super.register(); } clearDocumentDiagnostics(document) { this.diagnostics_db.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 virtual_document.uri to IEditorDiagnostic[]. */ get diagnostics_db() { // Note that virtual_editor can change at runtime (kernel restart) if (!diagnostics_databases.has(this.virtual_editor)) { diagnostics_databases.set(this.virtual_editor, new DiagnosticsDatabase()); } return diagnostics_databases.get(this.virtual_editor); } collapseOverlappingDiagnostics(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 range_id_to_range = new Map(); const range_id_to_diagnostics = new Map(); function get_range_id(range) { return (range.start.line + ',' + range.start.character + ',' + range.end.line + ',' + range.end.character); } diagnostics.forEach((diagnostic) => { let range = diagnostic.range; let range_id = get_range_id(range); range_id_to_range.set(range_id, range); if (range_id_to_diagnostics.has(range_id)) { let ranges_list = range_id_to_diagnostics.get(range_id); ranges_list.push(diagnostic); } else { range_id_to_diagnostics.set(range_id, [diagnostic]); } }); let map = new Map(); range_id_to_diagnostics.forEach((range_diagnostics, range_id) => { let range = range_id_to_range.get(range_id); map.set(range, range_diagnostics); }); return map; } get defaultSeverity() { return DiagnosticSeverity[this.settings.composite.defaultSeverity]; } filterDiagnostics(diagnostics) { const ignoredDiagnosticsCodes = new Set(this.settings.composite.ignoreCodes); 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 message = diagnostic.message; if (message && ignoredMessagesRegExp.some(pattern => pattern.test(message))) { return false; } return true; }); } setDiagnostics(response) { let diagnostics_list = []; // Note: no deep equal for Sets or Maps in JS const markers_to_retain = new Set(); // add new markers, keep track of the added ones // TODO: test case for severity class always being set, even if diagnostic has no severity let diagnostics_by_range = this.collapseOverlappingDiagnostics(this.filterDiagnostics(response.diagnostics)); diagnostics_by_range.forEach((diagnostics, range) => { const start = PositionConverter.lsp_to_cm(range.start); const end = PositionConverter.lsp_to_cm(range.end); const last_line_number = this.virtual_document.last_virtual_line - this.virtual_document.blank_lines_between_cells; if (start.line > last_line_number) { this.console.log(`Out of range diagnostic (${start.line} line > ${last_line_number}) was skipped `, diagnostics); return; } else { let last_line = this.virtual_document.last_line; if (start.line == last_line_number && start.ch > last_line.length) { this.console.log(`Out of range diagnostic (${start.ch} character > ${last_line.length} at line ${last_line_number}) was skipped `, diagnostics); return; } } let document; try { // assuming that we got a response for this document let start_in_root = this.transform_virtual_position_to_root_position(start); document = this.virtual_editor.document_at_root_position(start_in_root); } catch (e) { this.console.warn(`Could not place inspections from ${response.uri}`, ` inspections: `, diagnostics, 'error: ', e); return; } // This may happen if the response came delayed // and the user already changed the document so // that now this regions is in another virtual document! if (this.virtual_document !== document) { this.console.log(`Ignoring inspections from ${response.uri}`, ` (this region is covered by a another virtual document: ${document.uri})`, ` inspections: `, diagnostics); return; } if (document.virtual_lines .get(start.line) .skip_inspect.indexOf(document.id_path) !== -1) { this.console.log('Ignoring inspections silenced for this document:', diagnostics); return; } let ce_editor = document.get_editor_at_virtual_line(start); let cm_editor = this.virtual_editor.ce_editor_to_cm_editor.get(ce_editor); let start_in_editor = document.transform_virtual_to_editor(start); let end_in_editor; if (start_in_editor === null) { this.console.warn('Start in editor could not be be determined for', diagnostics); return; } // some servers return strange positions for ends try { end_in_editor = document.transform_virtual_to_editor(end); } catch (err) { this.console.warn('Malformed range for diagnostic', end); end_in_editor = Object.assign(Object.assign({}, start_in_editor), { ch: start_in_editor.ch + 1 }); } if (end_in_editor === null) { this.console.warn('End in editor could not be be determined for', diagnostics); return; } let range_in_editor = { start: start_in_editor, end: end_in_editor }; // what a pity there is no hash in the standard library... // we could use this: https://stackoverflow.com/a/7616484 though it may not be worth it: // the stringified diagnostic objects are only about 100-200 JS characters anyway, // depending on the message length; this could be reduced using some structure-aware // stringifier; such a stringifier could also prevent the possibility of having a false // negative due to a different ordering of keys // obviously, the hash would prevent recovery of info from the key. let diagnostic_hash = JSON.stringify({ // diagnostics without ranges diagnostics: diagnostics.map(diagnostic => [ diagnostic.severity, diagnostic.message, diagnostic.code, diagnostic.source, diagnostic.relatedInformation ]), // the apparent marker position will change in the notebook with every line change for each marker // after the (inserted/removed) line - but such markers should not be invalidated, // i.e. the invalidation should be performed in the cell space, not in the notebook coordinate space, // thus we transform the coordinates and keep the cell id in the hash range: range_in_editor, editor: this.unique_editor_ids.get(cm_editor) }); for (let diagnostic of diagnostics) { diagnostics_list.push({ diagnostic, editor: cm_editor, range: range_in_editor }); } markers_to_retain.add(diagnostic_hash); if (!this.marked_diagnostics.has(diagnostic_hash)) { const highestSeverityCode = diagnostics .map(diagnostic => diagnostic.severity || this.defaultSeverity) .sort()[0]; const severity = DiagnosticSeverity[highestSeverityCode]; const classNames = [ 'cm-lsp-diagnostic', 'cm-lsp-diagnostic-' + severity ]; const tags = []; for (let diagnostic of diagnostics) { if (diagnostic.tags) { tags.push(...diagnostic.tags); } } for (const tag of new Set(tags)) { classNames.push('cm-lsp-diagnostic-tag-' + DiagnosticTag[tag]); } let options = { title: diagnostics .map(d => d.message + (d.source ? ' (' + d.source + ')' : '')) .join('\n'), className: classNames.join(' ') }; let marker; try { marker = cm_editor .getDoc() .markText(start_in_editor, end_in_editor, options); } catch (e) { this.console.warn('Marking inspection (diagnostic text) failed:', diagnostics, e); return; } this.marked_diagnostics.set(diagnostic_hash, marker); } }); // remove the markers which were not included in the new message this.removeUnusedDiagnosticMarkers(markers_to_retain); this.diagnostics_db.set(this.virtual_document, diagnostics_list); } refreshDiagnostics() { if (this.last_response) { this.setDiagnostics(this.last_response); } diagnostics_panel.update(); } removeUnusedDiagnosticMarkers(to_retain) { this.marked_diagnostics.forEach((marker, diagnostic_hash) => { if (!to_retain.has(diagnostic_hash)) { this.marked_diagnostics.delete(diagnostic_hash); marker.clear(); } }); } remove() { this.settings.changed.disconnect(this.refreshDiagnostics, this); // remove all markers this.removeUnusedDiagnosticMarkers(new Set()); this.diagnostics_db.clear(); diagnostics_databases.delete(this.virtual_editor); this.unique_editor_ids.clear(); if (diagnostics_panel.content.model.virtual_editor === this.virtual_editor) { diagnostics_panel.content.model.virtual_editor = null; diagnostics_panel.content.model.diagnostics = null; diagnostics_panel.content.model.adapter = null; } diagnostics_panel.update(); super.remove(); } } //# sourceMappingURL=diagnostics.js.map