@jupyter-lsp/jupyterlab-lsp
Version:
Language Server Protocol integration for JupyterLab
236 lines • 10.5 kB
JavaScript
import { offsetAtPosition } from '@jupyterlab/lsp';
import { PositionConverter } from './converter';
import { DefaultMap, urisEqual } from './utils';
function offsetFromLsp(position, lines) {
return offsetAtPosition(PositionConverter.lsp_to_ce(position), lines);
}
function toDocumentChanges(changes) {
let documentChanges = [];
for (let uri of Object.keys(changes)) {
documentChanges.push({
textDocument: { uri },
edits: changes[uri]
});
}
return documentChanges;
}
export class EditApplicator {
constructor(virtualDocument, adapter) {
this.virtualDocument = virtualDocument;
this.adapter = adapter;
// no-op
}
async applyEdit(workspaceEdit) {
let currentUri = this.virtualDocument.documentInfo.uri;
// Specs: documentChanges are preferred over changes
let changes = workspaceEdit.documentChanges
? workspaceEdit.documentChanges.map(change => change)
: toDocumentChanges(workspaceEdit.changes);
let appliedChanges = 0;
let editedCells = 0;
let isWholeDocumentEdit = false;
let errors = [];
for (let change of changes) {
let uri = change.textDocument.uri;
if (!urisEqual(uri, currentUri)) {
errors.push(`Workspace-wide edits not implemented: ${uri} != ${currentUri}`);
}
else {
isWholeDocumentEdit =
change.edits.length === 1 &&
this._isWholeDocumentEdit(change.edits[0]);
let edit;
if (!isWholeDocumentEdit) {
appliedChanges = 0;
let value = this.virtualDocument.value;
// TODO: make sure that it was not changed since the request was sent (using the returned document version)
let lines = value.split('\n');
let editsByOffset = new Map();
for (let e of change.edits) {
let offset = offsetFromLsp(e.range.start, lines);
if (editsByOffset.has(offset)) {
console.warn('Edits should not overlap, ignoring an overlapping edit');
}
else {
editsByOffset.set(offset, e);
appliedChanges += 1;
}
}
// TODO make use of oldToNewLine for edits which add of remove lines:
// this is crucial to preserve cell boundaries in notebook in such cases
let oldToNewLine = new DefaultMap(() => []);
let newText = '';
let lastEnd = 0;
let currentOldLine = 0;
let currentNewLine = 0;
// going over the edits in descending order of start points:
let startOffsets = [...editsByOffset.keys()].sort((a, b) => a - b);
for (let start of startOffsets) {
let edit = editsByOffset.get(start);
let prefix = value.slice(lastEnd, start);
for (let i = 0; i < prefix.split('\n').length; i++) {
let newLines = oldToNewLine.getOrCreate(currentOldLine);
newLines.push(currentNewLine);
currentOldLine += 1;
currentNewLine += 1;
}
newText += prefix + edit.newText;
let end = offsetFromLsp(edit.range.end, lines);
let replacedFragment = value.slice(start, end);
for (let i = 0; i < edit.newText.split('\n').length; i++) {
if (i < replacedFragment.length) {
currentOldLine += 1;
}
currentNewLine += 1;
let newLines = oldToNewLine.getOrCreate(currentOldLine);
newLines.push(currentNewLine);
}
lastEnd = end;
}
newText += value.slice(lastEnd, value.length);
edit = {
range: {
start: { line: 0, character: 0 },
end: {
line: lines.length - 1,
character: lines[lines.length - 1].length
}
},
newText: newText
};
console.assert(this._isWholeDocumentEdit(edit));
}
else {
edit = change.edits[0];
appliedChanges += 1;
}
editedCells = this._applySingleEdit(edit);
}
}
const allEmpty = changes.every(change => change.edits.length === 0);
return {
appliedChanges: appliedChanges,
modifiedCells: editedCells,
wasGranular: !isWholeDocumentEdit && !allEmpty,
errors: errors
};
}
/**
* Does the edit cover the entire document?
*/
_isWholeDocumentEdit(edit) {
let value = this.virtualDocument.value;
let lines = value.split('\n');
let range = edit.range;
let lsp_to_ce = PositionConverter.lsp_to_ce;
return (offsetAtPosition(lsp_to_ce(range.start), lines) === 0 &&
offsetAtPosition(lsp_to_ce(range.end), lines) === value.length);
}
_replaceFragment(newText, editorAccessor, fragmentStart, fragmentEnd, start, end, isWholeDocumentEdit = false) {
let document = this.virtualDocument;
let newFragmentText = newText
.split('\n')
.slice(fragmentStart.line - start.line, fragmentEnd.line - start.line)
.join('\n');
if (newFragmentText.endsWith('\n')) {
newFragmentText = newFragmentText.slice(0, -1);
}
const editor = editorAccessor.getEditor();
if (!editor) {
throw Error('Editor is not accessible');
}
if (editor.host.closest('.jp-MarkdownCell')) {
// Workaround for https://github.com/jupyter-lsp/jupyterlab-lsp/issues/1008
// briefly, the rewrite for JupyterLab 4.0 added Markdown cell support, but they
// are extracted without trace in the top-level document. Here we avoid editing
// any markdown cell. Instead the clean solution would be to add an anchor marker
// to the top-level document.
return 0;
}
// TODO: should accessor present the model even if editor is not created yet?
const model = editor.model;
let rawValue = model.sharedModel.source;
// extract foreign documents and substitute magics,
// as it was done when the shadow virtual document was being created
let { lines } = document.prepareCodeBlock({
value: rawValue,
ceEditor: editorAccessor,
type: 'code'
});
let oldValue = lines.join('\n');
if (isWholeDocumentEdit) {
// partial edit
let cm_to_ce = PositionConverter.cm_to_ce;
let upToOffset = offsetAtPosition(cm_to_ce(start), lines);
let fromOffset = offsetAtPosition(cm_to_ce(end), lines);
newFragmentText =
oldValue.slice(0, upToOffset) + newText + oldValue.slice(fromOffset);
}
if (oldValue === newFragmentText) {
return 0;
}
let newValue = document.decodeCodeBlock(newFragmentText);
const cursor = editor.getCursorPosition();
model.sharedModel.updateSource(editor.getOffsetAt({ line: 0, column: 0 }), oldValue.length, newValue);
try {
// try to restore the cursor to the position prior to the edit
// (this might not be the best UX, but definitely better than
// the cursor jumping to the very end of the cell/file).
editor.setSelection({ start: cursor, end: cursor });
// note: this does not matter for actions invoke from context menu
// as those loose focus anyways (this might be addressed elsewhere)
}
catch (e) {
console.log('Could not place the cursor back', e);
}
return 1;
}
_applySingleEdit(edit) {
let document = this.virtualDocument;
let appliedChanges = 0;
let start = PositionConverter.lsp_to_cm(edit.range.start);
let end = PositionConverter.lsp_to_cm(edit.range.end);
let startEditor = document.getEditorAtVirtualLine(start);
let endEditor = document.getEditorAtVirtualLine(end);
if (startEditor !== endEditor) {
let lastEditor = startEditor;
let fragmentStart = start;
let fragmentEnd = { ...start };
let line = start.line;
let recentlyReplaced = false;
while (line <= end.line) {
line++;
let editor = document.getEditorAtVirtualLine({
line: line,
ch: 0
});
if (editor === lastEditor) {
fragmentEnd.line = line;
fragmentEnd.ch = 0;
recentlyReplaced = false;
}
else {
appliedChanges += this._replaceFragment(edit.newText, lastEditor, fragmentStart, fragmentEnd, start, end);
recentlyReplaced = true;
fragmentStart = {
line: line,
ch: 0
};
fragmentEnd = {
line: line,
ch: 0
};
lastEditor = editor;
}
}
if (!recentlyReplaced) {
appliedChanges += this._replaceFragment(edit.newText, lastEditor, fragmentStart, fragmentEnd, start, end);
}
}
else {
appliedChanges += this._replaceFragment(edit.newText, startEditor, start, end, start, end);
}
return appliedChanges;
}
}
//# sourceMappingURL=edits.js.map