UNPKG

@jupyterlab/lsp

Version:
884 lines 32.4 kB
// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import { Signal } from '@lumino/signaling'; import { DocumentConnectionManager } from '../connection_manager'; import { DefaultMap, untilReady } from '../utils'; /** * Check if given position is within range. * Both start and end are inclusive. * @param position * @param range */ export function isWithinRange(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 { /** * Creates an instance of VirtualDocumentInfo. * @param document - the virtual document need to * be wrapped. */ constructor(document) { /** * Current version of the virtual document. */ this.version = 0; this._document = document; } /** * Get the text content of the virtual document. */ get text() { return this._document.value; } /** * Get the uri of the virtual document, if the document is not available, * it returns an empty string, users need to check for the length of returned * value before using it. */ get uri() { const uris = DocumentConnectionManager.solveUris(this._document, this.languageId); if (!uris) { return ''; } return uris.document; } /** * Get the language identifier of the 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) { /** * Number of blank lines appended to the virtual document between * each cell. */ this.blankLinesBetweenCells = 2; this._isDisposed = false; this._foreignDocumentClosed = new Signal(this); this._foreignDocumentOpened = new Signal(this); this._changed = new Signal(this); this.options = options; this.path = this.options.path; this.fileExtension = options.fileExtension; this.hasLspSupportedFile = options.hasLspSupportedFile; this.parent = options.parent; this.language = options.language; this.virtualLines = new Map(); this.sourceLines = new Map(); this.foreignDocuments = new Map(); this._editorToSourceLine = new Map(); this._foreignCodeExtractors = options.foreignCodeExtractors; this.standalone = options.standalone || false; this.instanceId = VirtualDocument.instancesCount; VirtualDocument.instancesCount += 1; this.unusedStandaloneDocuments = new DefaultMap(() => new Array()); this._remainingLifetime = 6; this.documentInfo = new VirtualDocumentInfo(this); this.updateManager = new UpdateManager(this); this.updateManager.updateBegan.connect(this._updateBeganSlot, this); this.updateManager.blockAdded.connect(this._blockAddedSlot, this); this.updateManager.updateFinished.connect(this._updateFinishedSlot, this); this.clear(); } /** * Convert from code editor position into code mirror position. */ static ceToCm(position) { return { line: position.line, ch: position.column }; } /** * Test whether the document is disposed. */ get isDisposed() { return this._isDisposed; } /** * Signal emitted when the foreign document is closed */ get foreignDocumentClosed() { return this._foreignDocumentClosed; } /** * Signal emitted when the foreign document is opened */ get foreignDocumentOpened() { return this._foreignDocumentOpened; } /** * Signal emitted when the foreign document is changed */ get changed() { return this._changed; } /** * Id of the virtual document. */ get virtualId() { // for easier debugging, the language information is included in the ID: return this.standalone ? this.instanceId + '(' + this.language + ')' : this.language; } /** * Return the ancestry to this document. */ get ancestry() { if (!this.parent) { return [this]; } return this.parent.ancestry.concat([this]); } /** * Return the id path to the virtual document. */ get idPath() { if (!this.parent) { return this.virtualId; } return this.parent.idPath + '-' + this.virtualId; } /** * Get the uri of the virtual document. */ get uri() { const encodedPath = encodeURI(this.path); if (!this.parent) { return encodedPath; } return encodedPath + '.' + this.idPath + '.' + this.fileExtension; } /** * Get the text value of the document */ get value() { let linesPadding = '\n'.repeat(this.blankLinesBetweenCells); return this.lineBlocks.join(linesPadding); } /** * Get the last line in the virtual document */ get lastLine() { const linesInLastBlock = this.lineBlocks[this.lineBlocks.length - 1].split('\n'); return linesInLastBlock[linesInLastBlock.length - 1]; } /** * Get the root document of current virtual document. */ get root() { return this.parent ? this.parent.root : this; } /** * Dispose the virtual document. */ dispose() { if (this._isDisposed) { return; } this._isDisposed = true; this.parent = null; this.closeAllForeignDocuments(); this.updateManager.dispose(); // clear all the maps this.foreignDocuments.clear(); this.sourceLines.clear(); this.unusedStandaloneDocuments.clear(); this.virtualLines.clear(); // just to be sure - if anything is accessed after disposal (it should not) we // will get altered by errors in the console AND this will limit memory leaks this.documentInfo = null; this.lineBlocks = null; Signal.clearData(this); } /** * Clear the virtual document and all related stuffs */ clear() { this.unusedStandaloneDocuments.clear(); for (let document of this.foreignDocuments.values()) { document.clear(); if (document.standalone) { let set = this.unusedStandaloneDocuments.get(document.language); set.push(document); } } this.virtualLines.clear(); this.sourceLines.clear(); this.lastVirtualLine = 0; this.lastSourceLine = 0; this.lineBlocks = []; } /** * Get the virtual document from the cursor position of the source * document * @param position - position in source document */ documentAtSourcePosition(position) { let sourceLine = this.sourceLines.get(position.line); if (!sourceLine) { return this; } let sourcePositionCe = { line: sourceLine.editorLine, column: position.ch }; for (let [range, { virtualDocument: document }] of sourceLine.foreignDocumentsMap) { if (isWithinRange(sourcePositionCe, range)) { let sourcePositionCm = { line: sourcePositionCe.line - range.start.line, ch: sourcePositionCe.column - range.start.column }; return document.documentAtSourcePosition(sourcePositionCm); } } return this; } /** * Detect if the input source position is belong to the current * virtual document. * * @param sourcePosition - position in the source document */ isWithinForeign(sourcePosition) { let sourceLine = this.sourceLines.get(sourcePosition.line); let sourcePositionCe = { line: sourceLine.editorLine, column: sourcePosition.ch }; for (let [range] of sourceLine.foreignDocumentsMap) { if (isWithinRange(sourcePositionCe, range)) { return true; } } return false; } /** * Compute the position in root document from the position of * a child editor. * * @param editor - the active editor. * @param position - position in the active editor. */ transformFromEditorToRoot(editor, position) { if (!this._editorToSourceLine.has(editor)) { console.log('Editor not found in _editorToSourceLine map'); return null; } let shift = this._editorToSourceLine.get(editor); return { ...position, line: position.line + shift }; } /** * Compute the position in the virtual document from the position * if the source document. * * @param sourcePosition - position in source document */ virtualPositionAtDocument(sourcePosition) { let sourceLine = this.sourceLines.get(sourcePosition.line); if (sourceLine == null) { throw new Error('Source line not mapped to virtual position'); } let virtualLine = sourceLine.virtualLine; // position inside the cell (block) let sourcePositionCe = { line: sourceLine.editorLine, column: sourcePosition.ch }; for (let [range, content] of sourceLine.foreignDocumentsMap) { const { virtualLine, virtualDocument: document } = content; if (isWithinRange(sourcePositionCe, range)) { // position inside the foreign document block let sourcePositionCm = { line: sourcePositionCe.line - range.start.line, ch: sourcePositionCe.column - range.start.column }; if (document.isWithinForeign(sourcePositionCm)) { return this.virtualPositionAtDocument(sourcePositionCm); } else { // where in this block in the entire foreign document? sourcePositionCm.line += virtualLine; return sourcePositionCm; } } } return { ch: sourcePosition.ch, line: virtualLine }; } /** * Append a code block to the end of the virtual document. * * @param block - block to be appended * @param editorShift - position shift in source * document * @param [virtualShift] - position shift in * virtual document. */ appendCodeBlock(block, editorShift = { line: 0, column: 0 }, virtualShift) { let cellCode = block.value; let ceEditor = block.ceEditor; if (this.isDisposed) { console.warn('Cannot append code block: document disposed'); return; } let sourceCellLines = cellCode.split('\n'); let { lines, foreignDocumentsMap } = this.prepareCodeBlock(block, editorShift); for (let i = 0; i < lines.length; i++) { this.virtualLines.set(this.lastVirtualLine + i, { skipInspect: [], editor: ceEditor, // TODO this is incorrect, won't work if something was extracted sourceLine: this.lastSourceLine + i }); } for (let i = 0; i < sourceCellLines.length; i++) { this.sourceLines.set(this.lastSourceLine + i, { editorLine: i, editorShift: { line: editorShift.line - ((virtualShift === null || virtualShift === void 0 ? void 0 : virtualShift.line) || 0), column: i === 0 ? editorShift.column - ((virtualShift === null || virtualShift === void 0 ? void 0 : virtualShift.column) || 0) : 0 }, // TODO: move those to a new abstraction layer (DocumentBlock class) editor: ceEditor, foreignDocumentsMap, // TODO this is incorrect, won't work if something was extracted virtualLine: this.lastVirtualLine + i }); } this.lastVirtualLine += 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.lineBlocks.push(lines.join('\n') + '\n'); // adding the virtual lines for the blank lines for (let i = 0; i < this.blankLinesBetweenCells; i++) { this.virtualLines.set(this.lastVirtualLine + i, { skipInspect: [this.idPath], editor: ceEditor, sourceLine: null }); } this.lastVirtualLine += this.blankLinesBetweenCells; this.lastSourceLine += sourceCellLines.length; } /** * Extract a code block into list of string in supported language and * a map of foreign document if any. * @param block - block to be appended * @param editorShift - position shift in source document */ prepareCodeBlock(block, editorShift = { line: 0, column: 0 }) { let { cellCodeKept, foreignDocumentsMap } = this.extractForeignCode(block, editorShift); let lines = cellCodeKept.split('\n'); return { lines, foreignDocumentsMap }; } /** * Extract the foreign code from input block by using the registered * extractors. * @param block - block to be appended * @param editorShift - position shift in source document */ extractForeignCode(block, editorShift) { let foreignDocumentsMap = new Map(); let cellCode = block.value; const extractorsForAnyLang = this._foreignCodeExtractors.getExtractors(block.type, null); const extractorsForCurrentLang = this._foreignCodeExtractors.getExtractors(block.type, this.language); for (let extractor of [ ...extractorsForAnyLang, ...extractorsForCurrentLang ]) { if (!extractor.hasForeignCode(cellCode, block.type)) { continue; } let results = extractor.extractForeignCode(cellCode); let keptCellCode = ''; for (let result of results) { if (result.foreignCode !== null) { // result.range should only be null if result.foregin_code is null if (result.range === null) { console.log('Failure in foreign code extraction: `range` is null but `foreign_code` is not!'); continue; } let foreignDocument = this._chooseForeignDocument(extractor); foreignDocumentsMap.set(result.range, { virtualLine: foreignDocument.lastVirtualLine, virtualDocument: foreignDocument, editor: block.ceEditor }); let foreignShift = { line: editorShift.line + result.range.start.line, column: editorShift.column + result.range.start.column }; foreignDocument.appendCodeBlock({ value: result.foreignCode, ceEditor: block.ceEditor, type: 'code' }, foreignShift, result.virtualShift); } if (result.hostCode != null) { keptCellCode += result.hostCode; } } // 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). cellCode = keptCellCode; } return { cellCodeKept: cellCode, foreignDocumentsMap }; } /** * Close a foreign document and disconnect all associated signals */ closeForeign(document) { this._foreignDocumentClosed.emit({ foreignDocument: document, parentHost: this }); // remove it from foreign documents list this.foreignDocuments.delete(document.virtualId); // and delete the documents within it document.closeAllForeignDocuments(); document.foreignDocumentClosed.disconnect(this.forwardClosedSignal, this); document.foreignDocumentOpened.disconnect(this.forwardOpenedSignal, this); document.dispose(); } /** * Close all foreign documents. */ closeAllForeignDocuments() { for (let document of this.foreignDocuments.values()) { this.closeForeign(document); } } /** * Close all expired documents. */ closeExpiredDocuments() { const usedDocuments = new Set(); for (const line of this.sourceLines.values()) { for (const block of line.foreignDocumentsMap.values()) { usedDocuments.add(block.virtualDocument); } } const documentIDs = new Map(); for (const [id, document] of this.foreignDocuments.entries()) { const ids = documentIDs.get(document); if (typeof ids !== 'undefined') { documentIDs.set(document, [...ids, id]); } documentIDs.set(document, [id]); } const allDocuments = new Set(documentIDs.keys()); const unusedVirtualDocuments = new Set([...allDocuments].filter(x => !usedDocuments.has(x))); for (let document of unusedVirtualDocuments.values()) { document.remainingLifetime -= 1; if (document.remainingLifetime <= 0) { document.dispose(); const ids = documentIDs.get(document); for (const id of ids) { this.foreignDocuments.delete(id); } } } } /** * Transform the position of the source to the editor * position. * * @param pos - position in the source document * @return position in the editor. */ transformSourceToEditor(pos) { let sourceLine = this.sourceLines.get(pos.line); let editorLine = sourceLine.editorLine; let editorShift = sourceLine.editorShift; 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 + (editorLine === 0 ? editorShift.column : 0), line: editorLine + editorShift.line // TODO or: // line: pos.line + editor_shift.line - this.first_line_of_the_block(editor) }; } /** * Transform the position in the virtual document to the * editor 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 * and thus they are absent in the editor. */ transformVirtualToEditor(virtualPosition) { let sourcePosition = this.transformVirtualToSource(virtualPosition); if (sourcePosition == null) { return null; } return this.transformSourceToEditor(sourcePosition); } /** * Transform the position in the virtual document to the source. * Can be null because some lines are added as padding/anchors * to the virtual document and those do not exist in the source document. */ transformVirtualToSource(position) { const line = this.virtualLines.get(position.line).sourceLine; if (line == null) { return null; } return { ch: position.ch, line: line }; } /** * Compute the position in root document from the position of * a virtual document. */ transformVirtualToRoot(position) { var _a; const editor = (_a = this.virtualLines.get(position.line)) === null || _a === void 0 ? void 0 : _a.editor; const editorPosition = this.transformVirtualToEditor(position); if (!editor || !editorPosition) { return null; } return this.root.transformFromEditorToRoot(editor, editorPosition); } /** * Get the corresponding editor of the virtual line. */ getEditorAtVirtualLine(pos) { let line = pos.line; // tolerate overshot by one (the hanging blank line at the end) if (!this.virtualLines.has(line)) { line -= 1; } return this.virtualLines.get(line).editor; } /** * Get the corresponding editor of the source line */ getEditorAtSourceLine(pos) { return this.sourceLines.get(pos.line).editor; } /** * Recursively emits changed signal from the document or any descendant foreign document. */ maybeEmitChanged() { if (this.value !== this.previousValue) { this._changed.emit(this); } this.previousValue = this.value; for (let document of this.foreignDocuments.values()) { document.maybeEmitChanged(); } } /** * 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 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 remainingLifetime() { if (!this.parent) { return Infinity; } return this._remainingLifetime; } set remainingLifetime(value) { if (this.parent) { this._remainingLifetime = value; } } /** * Get the foreign document that can be opened with the input extractor. */ _chooseForeignDocument(extractor) { let foreignDocument; // if not standalone, try to append to existing document let foreignExists = this.foreignDocuments.has(extractor.language); if (!extractor.standalone && foreignExists) { foreignDocument = this.foreignDocuments.get(extractor.language); } else { // if (previous document does not exists) or (extractor produces standalone documents // and no old standalone document could be reused): create a new document let unusedStandalone = this.unusedStandaloneDocuments.get(extractor.language); if (extractor.standalone && unusedStandalone.length > 0) { foreignDocument = unusedStandalone.pop(); } else { foreignDocument = this.openForeign(extractor.language, extractor.standalone, extractor.fileExtension); } } return foreignDocument; } /** * Create a foreign document from input language and file extension. * * @param language - the required language * @param standalone - the document type is supported natively by LSP? * @param fileExtension - File extension. */ openForeign(language, standalone, fileExtension) { let document = new this.constructor({ ...this.options, parent: this, standalone: standalone, fileExtension: fileExtension, language: language }); const context = { foreignDocument: document, parentHost: this }; this._foreignDocumentOpened.emit(context); // pass through any future signals document.foreignDocumentClosed.connect(this.forwardClosedSignal, this); document.foreignDocumentOpened.connect(this.forwardOpenedSignal, this); this.foreignDocuments.set(document.virtualId, document); return document; } /** * Forward the closed signal from the foreign document to the host document's * signal */ forwardClosedSignal(host, context) { this._foreignDocumentClosed.emit(context); } /** * Forward the opened signal from the foreign document to the host document's * signal */ forwardOpenedSignal(host, context) { this._foreignDocumentOpened.emit(context); } /** * Slot of the `updateBegan` signal. */ _updateBeganSlot() { this._editorToSourceLineNew = new Map(); } /** * Slot of the `blockAdded` signal. */ _blockAddedSlot(updateManager, blockData) { this._editorToSourceLineNew.set(blockData.block.ceEditor, blockData.virtualDocument.lastSourceLine); } /** * Slot of the `updateFinished` signal. */ _updateFinishedSlot() { this._editorToSourceLine = this._editorToSourceLineNew; } } VirtualDocument.instancesCount = 0; /** * Create foreign documents if available from input virtual documents. * @param virtualDocument - the virtual document to be collected * @return - Set of generated foreign documents */ export function collectDocuments(virtualDocument) { let collected = new Set(); collected.add(virtualDocument); for (let foreign of virtualDocument.foreignDocuments.values()) { let foreignLanguages = collectDocuments(foreign); foreignLanguages.forEach(collected.add, collected); } return collected; } export class UpdateManager { constructor(virtualDocument) { this.virtualDocument = virtualDocument; this._isDisposed = false; /** * Promise resolved when the updating process finishes. */ this._updateDone = new Promise(resolve => { resolve(); }); /** * Virtual documents update guard. */ this._isUpdateInProgress = false; /** * Update lock to prevent multiple updates are applied at the same time. */ this._updateLock = false; this._blockAdded = new Signal(this); this._documentUpdated = new Signal(this); this._updateBegan = new Signal(this); this._updateFinished = new Signal(this); this.documentUpdated.connect(this._onUpdated, this); } /** * Promise resolved when the updating process finishes. */ get updateDone() { return this._updateDone; } /** * Test whether the document is disposed. */ get isDisposed() { return this._isDisposed; } /** * Signal emitted when a code block is added to the document. */ get blockAdded() { return this._blockAdded; } /** * Signal emitted by the editor that triggered the update, * providing the root document of the updated documents. */ get documentUpdated() { return this._documentUpdated; } /** * Signal emitted when the update is started */ get updateBegan() { return this._updateBegan; } /** * Signal emitted when the update is finished */ get updateFinished() { return this._updateFinished; } /** * Dispose the class */ dispose() { if (this._isDisposed) { return; } this._isDisposed = true; this.documentUpdated.disconnect(this._onUpdated); Signal.clearData(this); } /** * 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 withUpdateLock(fn) { await untilReady(() => this._canUpdate(), 12, 10).then(() => { try { this._updateLock = true; fn(); } finally { this._updateLock = 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 updateDocuments(blocks) { let update = new Promise((resolve, reject) => { // defer the update by up to 50 ms (10 retrials * 5 ms break), // awaiting for the previous update to complete. untilReady(() => this._canUpdate(), 10, 5) .then(() => { if (this.isDisposed || !this.virtualDocument) { resolve(); } try { this._isUpdateInProgress = true; this._updateBegan.emit(blocks); this.virtualDocument.clear(); for (let codeBlock of blocks) { this._blockAdded.emit({ block: codeBlock, virtualDocument: this.virtualDocument }); this.virtualDocument.appendCodeBlock(codeBlock); } this._updateFinished.emit(blocks); if (this.virtualDocument) { this._documentUpdated.emit(this.virtualDocument); this.virtualDocument.maybeEmitChanged(); } resolve(); } catch (e) { console.warn('Documents update failed:', e); reject(e); } finally { this._isUpdateInProgress = false; } }) .catch(console.error); }); this._updateDone = update; return update; } /** * Once all the foreign documents were refreshed, the unused documents (and their connections) * should be terminated if their lifetime has expired. */ _onUpdated(manager, rootDocument) { try { rootDocument.closeExpiredDocuments(); } catch (e) { console.warn('Failed to close expired documents'); } } /** * Check if the document can be updated. */ _canUpdate() { return !this.isDisposed && !this._isUpdateInProgress && !this._updateLock; } } //# sourceMappingURL=document.js.map