@jupyterlab/lsp
Version:
884 lines • 32.4 kB
JavaScript
// 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