UNPKG

@jupyter-lsp/jupyterlab-lsp

Version:

Language Server Protocol integration for JupyterLab

236 lines 10.5 kB
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