UNPKG

@krassowski/jupyterlab-lsp

Version:

Language Server Protocol integration for JupyterLab

644 lines 27.1 kB
import { Signal } from '@lumino/signaling'; import { DocumentConnectionManager } from '../connection_manager'; import { ReversibleOverridesMap } from '../overrides/maps'; import { PositionError } from '../positioning'; import { DefaultMap, until_ready } from '../utils'; import { BrowserConsole } from './console'; /** * Check if given position is within range. * Both start and end are inclusive. * @param position * @param range */ export function is_within_range(position, range) { if (range.start.line === range.end.line) { return (position.line === range.start.line && position.column >= range.start.column && position.column <= range.end.column); } return ((position.line === range.start.line && position.column >= range.start.column && position.line < range.end.line) || (position.line > range.start.line && position.column <= range.end.column && position.line === range.end.line) || (position.line > range.start.line && position.line < range.end.line)); } /** * a virtual implementation of IDocumentInfo */ export class VirtualDocumentInfo { constructor(document) { this.version = 0; this._document = document; } get text() { return this._document.value; } get uri() { const uris = DocumentConnectionManager.solve_uris(this._document, this.languageId); return uris.document; } get languageId() { return this._document.language; } } /** * A notebook can hold one or more virtual documents; there is always one, * "root" document, corresponding to the language of the kernel. All other * virtual documents are extracted out of the notebook, based on magics, * or other syntax constructs, depending on the kernel language. * * Virtual documents represent the underlying code in a single language, * which has been parsed excluding interactive kernel commands (magics) * which could be misunderstood by the specific LSP server. * * VirtualDocument has no awareness of the notebook or editor it lives in, * however it is able to transform its content back to the notebook space, * as it keeps editor coordinates for each virtual line. * * The notebook/editor aware transformations are preferred to be placed in * VirtualEditor descendants rather than here. * * No dependency on editor implementation (such as CodeMirrorEditor) * is allowed for VirtualEditor. */ export class VirtualDocument { constructor(options) { this.isDisposed = false; // TODO: make this configurable, depending on the language used this.blank_lines_between_cells = 2; this.options = options; this.path = options.path; this.console = options.console; this.file_extension = options.file_extension; this.has_lsp_supported_file = options.has_lsp_supported_file; this.parent = options.parent; this.language = options.language; let overrides = this.language in options.overrides_registry ? options.overrides_registry[this.language] : null; this.cell_magics_overrides = new ReversibleOverridesMap(overrides ? overrides.cell : []); this.line_magics_overrides = new ReversibleOverridesMap(overrides ? overrides.line : []); this.foreign_extractors_registry = options.foreign_code_extractors; this.foreign_extractors = this.language in this.foreign_extractors_registry ? this.foreign_extractors_registry[this.language] : []; this.virtual_lines = new Map(); this.source_lines = new Map(); this.foreign_documents = new Map(); this.overrides_registry = options.overrides_registry; this.standalone = options.standalone || false; this.instance_id = VirtualDocument.instances_count; VirtualDocument.instances_count += 1; this.unused_standalone_documents = new DefaultMap(() => new Array()); this._remaining_lifetime = 6; this.foreign_document_closed = new Signal(this); this.foreign_document_opened = new Signal(this); this.changed = new Signal(this); this.unused_documents = new Set(); this.document_info = new VirtualDocumentInfo(this); this.update_manager = new UpdateManager(this, options.console); this.clear(); } dispose() { if (this.isDisposed) { return; } this.isDisposed = true; this.parent = null; for (const doc of this.foreign_documents.values()) { doc.dispose(); } this.close_all_foreign_documents(); this.update_manager.dispose(); // clear all the maps this.foreign_documents.clear(); this.source_lines.clear(); this.unused_documents.clear(); this.unused_standalone_documents.clear(); this.virtual_lines.clear(); // just to be sure - if anything is accessed after disposal (it should not) we // will get alterted by errors in the console AND this will limit memory leaks this.cell_magics_overrides = null; this.document_info = null; this.foreign_extractors = null; this.foreign_extractors_registry = null; this.line_magics_overrides = null; this.line_blocks = null; this.overrides_registry = null; } /** * When this counter goes down to 0, the document will be destroyed and the associated connection will be closed; * This is meant to reduce the number of open connections when a a foreign code snippet was removed from the document. * * Note: top level virtual documents are currently immortal (unless killed by other means); it might be worth * implementing culling of unused documents, but if and only if JupyterLab will also implement culling of * idle kernels - otherwise the user experience could be a bit inconsistent, and we would need to invent our own rules. */ get remaining_lifetime() { if (!this.parent) { return Infinity; } return this._remaining_lifetime; } set remaining_lifetime(value) { if (this.parent) { this._remaining_lifetime = value; } } clear() { // TODO - deep clear (assure that there is no memory leak) this.unused_standalone_documents.clear(); for (let document of this.foreign_documents.values()) { document.clear(); if (document.standalone) { // once the standalone document was cleared, we may want to remove it and close connection; // but wait, this is a waste of resources (opening a connection takes 1-3 seconds) and, // since this is cleaned anyway, we could use it for another standalone document of the same language. let set = this.unused_standalone_documents.get(document.language); set.push(document); } } this.unused_documents = new Set(this.foreign_documents.values()); this.virtual_lines.clear(); this.source_lines.clear(); this.last_virtual_line = 0; this.last_source_line = 0; this.line_blocks = []; } forward_closed_signal(host, context) { this.foreign_document_closed.emit(context); } forward_opened_signal(host, context) { this.foreign_document_opened.emit(context); } // TODO: what could be refactored into "ForeignDocumentsManager" has started to emerge; // we should consider refactoring later on. open_foreign(language, standalone, file_extension) { let document = new VirtualDocument(Object.assign(Object.assign({}, this.options), { parent: this, standalone: standalone, file_extension: file_extension, language: language })); const context = { foreign_document: document, parent_host: this }; this.foreign_document_opened.emit(context); // pass through any future signals document.foreign_document_closed.connect(this.forward_closed_signal, this); document.foreign_document_opened.connect(this.forward_opened_signal, this); this.foreign_documents.set(document.virtual_id, document); return document; } document_at_source_position(position) { let source_line = this.source_lines.get(position.line); if (source_line == null) { return this; } let source_position_ce = { line: source_line.editor_line, column: position.ch }; for (let [range, { virtual_document: document }] of source_line.foreign_documents_map) { if (is_within_range(source_position_ce, range)) { let source_position_cm = { line: source_position_ce.line - range.start.line, ch: source_position_ce.column - range.start.column }; return document.document_at_source_position(source_position_cm); } } return this; } is_within_foreign(source_position) { let source_line = this.source_lines.get(source_position.line); let source_position_ce = { line: source_line.editor_line, column: source_position.ch }; for (let [range] of source_line.foreign_documents_map) { if (is_within_range(source_position_ce, range)) { return true; } } return false; } virtual_position_at_document(source_position) { let source_line = this.source_lines.get(source_position.line); if (source_line == null) { throw new PositionError('Source line not mapped to virtual position'); } let virtual_line = source_line.virtual_line; // position inside the cell (block) let source_position_ce = { line: source_line.editor_line, column: source_position.ch }; for (let [range, { virtual_line, virtual_document: document }] of source_line.foreign_documents_map) { if (is_within_range(source_position_ce, range)) { // position inside the foreign document block let source_position_cm = { line: source_position_ce.line - range.start.line, ch: source_position_ce.column - range.start.column }; if (document.is_within_foreign(source_position_cm)) { return this.virtual_position_at_document(source_position_cm); } else { // where in this block in the entire foreign document? source_position_cm.line += virtual_line; return source_position_cm; } } } return { ch: source_position.ch, line: virtual_line }; } choose_foreign_document(extractor) { let foreign_document; // if not standalone, try to append to existing document let foreign_exists = this.foreign_documents.has(extractor.language); if (!extractor.standalone && foreign_exists) { foreign_document = this.foreign_documents.get(extractor.language); this.unused_documents.delete(foreign_document); } else { // if standalone, try to re-use existing connection to the server let unused_standalone = this.unused_standalone_documents.get(extractor.language); if (extractor.standalone && unused_standalone.length > 0) { foreign_document = unused_standalone.pop(); this.unused_documents.delete(foreign_document); } else { // if (previous document does not exists) or (extractor produces standalone documents // and no old standalone document could be reused): create a new document foreign_document = this.open_foreign(extractor.language, extractor.standalone, extractor.file_extension); } } return foreign_document; } extract_foreign_code(block, editor_shift) { let foreign_document_map = new Map(); let cell_code = block.value; for (let extractor of this.foreign_extractors) { // first, check if there is any foreign code: if (!extractor.has_foreign_code(cell_code)) { continue; } let results = extractor.extract_foreign_code(cell_code); let kept_cell_code = ''; for (let result of results) { if (result.foreign_code !== null) { // result.range should only be null if result.foregin_code is null if (result.range === null) { this.console.warn('Failure in foreign code extraction: `range` is null but `foreign_code` is not!'); continue; } let foreign_document = this.choose_foreign_document(extractor); foreign_document_map.set(result.range, { virtual_line: foreign_document.last_virtual_line, virtual_document: foreign_document, editor: block.ce_editor }); let foreign_shift = { line: editor_shift.line + result.range.start.line, column: editor_shift.column + result.range.start.column }; foreign_document.append_code_block({ value: result.foreign_code, ce_editor: block.ce_editor }, foreign_shift, result.virtual_shift); } if (result.host_code != null) { kept_cell_code += result.host_code; } } // not breaking - many extractors are allowed to process the code, one after each other // (think JS and CSS in HTML, or %R inside of %%timeit). cell_code = kept_cell_code; } return { cell_code_kept: cell_code, foreign_document_map }; } decode_code_block(raw_code) { // TODO: add back previously extracted foreign code let cell_override = this.cell_magics_overrides.reverse.override_for(raw_code); if (cell_override != null) { return cell_override; } else { let lines = this.line_magics_overrides.reverse_replace_all(raw_code.split('\n')); return lines.join('\n'); } } prepare_code_block(block, editor_shift = { line: 0, column: 0 }) { let lines; let skip_inspect; let { cell_code_kept, foreign_document_map } = this.extract_foreign_code(block, editor_shift); let cell_code = cell_code_kept; // cell magics are replaced if requested and matched let cell_override = this.cell_magics_overrides.override_for(cell_code); if (cell_override != null) { lines = cell_override.split('\n'); skip_inspect = lines.map(l => [this.id_path]); } else { // otherwise, we replace line magics - if any let result = this.line_magics_overrides.replace_all(cell_code.split('\n')); lines = result.lines; skip_inspect = result.skip_inspect.map(skip => skip ? [this.id_path] : []); } return { lines, foreign_document_map, skip_inspect }; } get foreign_document_maps() { let maps = new Set(); for (let line of this.source_lines.values()) { maps.add(line.foreign_documents_map); } return [...maps.values()]; } append_code_block(block, editor_shift = { line: 0, column: 0 }, virtual_shift) { let cell_code = block.value; let ce_editor = block.ce_editor; if (this.isDisposed) { console.warn('Cannot append code block: document disposed'); return; } let source_cell_lines = cell_code.split('\n'); let { lines, foreign_document_map, skip_inspect } = this.prepare_code_block(block, editor_shift); for (let i = 0; i < lines.length; i++) { this.virtual_lines.set(this.last_virtual_line + i, { skip_inspect: skip_inspect[i], editor: ce_editor, // TODO this is incorrect, wont work if something was extracted source_line: this.last_source_line + i }); } for (let i = 0; i < source_cell_lines.length; i++) { this.source_lines.set(this.last_source_line + i, { editor_line: i, editor_shift: { line: editor_shift.line - ((virtual_shift === null || virtual_shift === void 0 ? void 0 : virtual_shift.line) || 0), column: i === 0 ? editor_shift.column - ((virtual_shift === null || virtual_shift === void 0 ? void 0 : virtual_shift.column) || 0) : 0 }, // TODO: move those to a new abstraction layer (DocumentBlock class) editor: ce_editor, foreign_documents_map: foreign_document_map, // TODO this is incorrect, wont work if something was extracted virtual_line: this.last_virtual_line + i }); } this.last_virtual_line += lines.length; // one empty line is necessary to separate code blocks, next 'n' lines are to silence linters; // the final cell does not get the additional lines (thanks to the use of join, see below) this.line_blocks.push(lines.join('\n') + '\n'); // adding the virtual lines for the blank lines for (let i = 0; i < this.blank_lines_between_cells; i++) { this.virtual_lines.set(this.last_virtual_line + i, { skip_inspect: [this.id_path], editor: ce_editor, source_line: null }); } this.last_virtual_line += this.blank_lines_between_cells; this.last_source_line += source_cell_lines.length; } get value() { let lines_padding = '\n'.repeat(this.blank_lines_between_cells); return this.line_blocks.join(lines_padding); } get last_line() { const lines_in_last_block = this.line_blocks[this.line_blocks.length - 1].split('\n'); return lines_in_last_block[lines_in_last_block.length - 1]; } close_expired_documents() { for (let document of this.unused_documents.values()) { document.remaining_lifetime -= 1; if (document.remaining_lifetime <= 0) { this.close_foreign(document); } } } close_foreign(document) { console.log('LSP: closing document', document); this.foreign_document_closed.emit({ foreign_document: document, parent_host: this }); // remove it from foreign documents list this.foreign_documents.delete(document.virtual_id); // and delete the documents within it document.close_all_foreign_documents(); document.foreign_document_closed.disconnect(this.forward_closed_signal, this); document.foreign_document_opened.disconnect(this.forward_opened_signal, this); } close_all_foreign_documents() { for (let document of this.foreign_documents.values()) { this.close_foreign(document); } } get virtual_id() { // for easier debugging, the language information is included in the ID: return this.standalone ? this.instance_id + '(' + this.language + ')' : this.language; } get ancestry() { if (!this.parent) { return [this]; } return this.parent.ancestry.concat([this]); } get id_path() { if (!this.parent) { return this.virtual_id; } return this.parent.id_path + '-' + this.virtual_id; } get uri() { const encodedPath = encodeURI(this.path); if (!this.parent) { return encodedPath; } return encodedPath + '.' + this.id_path + '.' + this.file_extension; } transform_source_to_editor(pos) { let source_line = this.source_lines.get(pos.line); let editor_line = source_line.editor_line; let editor_shift = source_line.editor_shift; return { // only shift column in the line beginning the virtual document (first list of the editor in cell magics, but might be any line of editor in line magics!) ch: pos.ch + (editor_line === 0 ? editor_shift.column : 0), line: editor_line + editor_shift.line // TODO or: // line: pos.line + editor_shift.line - this.first_line_of_the_block(editor) }; } /** Can be null because some lines are added as padding/anchors to the virtual document and those do not exist in the source document and thus they are absent in the editor. */ transform_virtual_to_editor(virtual_position) { let source_position = this.transform_virtual_to_source(virtual_position); if (source_position == null) { return null; } return this.transform_source_to_editor(source_position); } /** Can be null because some lines are added as padding/anchors to the virtual document and those do not exist in the source document. */ transform_virtual_to_source(position) { const line = this.virtual_lines.get(position.line).source_line; if (line == null) { return null; } return { ch: position.ch, line: line }; } get root() { if (this.parent == null) { return this; } return this.parent.root; } get_editor_at_virtual_line(pos) { let line = pos.line; // tolerate overshot by one (the hanging blank line at the end) if (!this.virtual_lines.has(line)) { line -= 1; } return this.virtual_lines.get(line).editor; } get_editor_at_source_line(pos) { return this.source_lines.get(pos.line).editor; } /** * Recursively emits changed signal from the document or any descendant foreign document. */ maybe_emit_changed() { if (this.value !== this.previous_value) { this.changed.emit(this); } this.previous_value = this.value; for (let document of this.foreign_documents.values()) { document.maybe_emit_changed(); } } } VirtualDocument.instances_count = 0; export function collect_documents(virtual_document) { let collected = new Set(); collected.add(virtual_document); for (let foreign of virtual_document.foreign_documents.values()) { let foreign_languages = collect_documents(foreign); foreign_languages.forEach(collected.add, collected); } return collected; } export class UpdateManager { constructor(virtual_document, console) { this.virtual_document = virtual_document; /** * Virtual documents update guard. */ this.is_update_in_progress = false; this.update_lock = false; this.isDisposed = false; this.update_done = new Promise(resolve => { resolve(); }); this.document_updated = new Signal(this); this.block_added = new Signal(this); this.update_began = new Signal(this); this.update_finished = new Signal(this); this.document_updated.connect(this.on_updated, this); this.console = console || new BrowserConsole(); } dispose() { if (this.isDisposed) { return; } this.document_updated.disconnect(this.on_updated, this); } /** * Once all the foreign documents were refreshed, the unused documents (and their connections) * should be terminated if their lifetime has expired. */ on_updated(manager, root_document) { try { root_document.close_expired_documents(); } catch (e) { this.console.warn('Failed to close expired documents'); } } can_update() { return !this.isDisposed && !this.is_update_in_progress && !this.update_lock; } /** * Execute provided callback within an update-locked context, which guarantees that: * - the previous updates must have finished before the callback call, and * - no update will happen when executing the callback * @param fn - the callback to execute in update lock */ async with_update_lock(fn) { await until_ready(() => this.can_update(), 12, 10).then(() => { try { this.update_lock = true; fn(); } finally { this.update_lock = false; } }); } /** * Update all the virtual documents, emit documents updated with root document if succeeded, * and resolve a void promise. The promise does not contain the text value of the root document, * as to avoid an easy trap of ignoring the changes in the virtual documents. */ async update_documents(blocks) { let update = new Promise(async (resolve, reject) => { // defer the update by up to 50 ms (10 retrials * 5 ms break), // awaiting for the previous update to complete. await until_ready(() => this.can_update(), 10, 5).then(() => { if (this.isDisposed || !this.virtual_document) { resolve(); } try { this.is_update_in_progress = true; this.update_began.emit(blocks); this.virtual_document.clear(); for (let code_block of blocks) { this.block_added.emit({ block: code_block, virtual_document: this.virtual_document }); this.virtual_document.append_code_block(code_block); } this.update_finished.emit(blocks); if (this.virtual_document) { this.document_updated.emit(this.virtual_document); this.virtual_document.maybe_emit_changed(); } resolve(); } catch (e) { this.console.warn('Documents update failed:', e); reject(e); } finally { this.is_update_in_progress = false; } }); }); this.update_done = update; return update; } } //# sourceMappingURL=document.js.map