@krassowski/jupyterlab-lsp
Version:
Language Server Protocol integration for JupyterLab
522 lines (467 loc) • 16.2 kB
text/typescript
import { CodeEditor } from '@jupyterlab/codeeditor';
import { IDocumentWidget } from '@jupyterlab/docregistry';
import { TranslationBundle } from '@jupyterlab/translation';
import type * as CodeMirror from 'codemirror';
import type * as lsProtocol from 'vscode-languageserver-protocol';
import { StatusMessage, WidgetAdapter } from '../adapters/adapter';
import { LSPConnection } from '../connection';
import { PositionConverter } from '../converter';
import {
IEditorIntegrationOptions,
IFeature,
IFeatureEditorIntegration,
IFeatureSettings
} from '../feature';
import {
IEditorPosition,
IRootPosition,
IVirtualPosition,
offset_at_position
} from '../positioning';
import { ILSPLogConsole } from '../tokens';
import { DefaultMap, uris_equal } from '../utils';
import {
CodeMirrorHandler,
CodeMirrorVirtualEditor,
EventName as CodeMirrorEventName
} from '../virtual/codemirror_editor';
import { VirtualDocument } from '../virtual/document';
import { IEditorChange } from '../virtual/editor';
function toDocumentChanges(changes: {
[uri: string]: lsProtocol.TextEdit[];
}): lsProtocol.TextDocumentEdit[] {
let documentChanges = [];
for (let uri of Object.keys(changes)) {
documentChanges.push({
textDocument: { uri },
edits: changes[uri]
} as lsProtocol.TextDocumentEdit);
}
return documentChanges;
}
export interface IEditorRange {
start: IEditorPosition;
end: IEditorPosition;
editor: CodeMirror.Editor;
}
function offset_from_lsp(position: lsProtocol.Position, lines: string[]) {
return offset_at_position(PositionConverter.lsp_to_ce(position), lines);
}
export interface IEditOutcome {
appliedChanges: number | null;
modifiedCells: number;
wasGranular: boolean;
errors: string[];
}
/**
* Interface for storage of HTMLElement event specifications (event name + handler).
*/
interface IHTMLEventMap<
T extends keyof HTMLElementEventMap = keyof HTMLElementEventMap
> extends Map<T, (event: HTMLElementEventMap[T]) => void> {
set<E extends T>(
k: E,
handler: (event: HTMLElementEventMap[E]) => void
): this;
get<E extends T>(k: E): (event: HTMLElementEventMap[E]) => void;
}
/**
* One feature of each type exists per VirtualDocument
* (the initialization is performed by the adapter).
*/
export abstract class CodeMirrorIntegration
implements IFeatureEditorIntegration<CodeMirrorVirtualEditor>
{
is_registered: boolean;
feature: IFeature;
protected readonly editor_handlers: Map<
CodeMirrorEventName,
CodeMirrorHandler
>;
// TODO use better type constraints for connection event names and for responses
protected readonly connection_handlers: Map<
string,
(response: Record<string, any>) => void
>;
protected readonly wrapper_handlers: IHTMLEventMap;
protected wrapper: HTMLElement;
protected virtual_editor: CodeMirrorVirtualEditor;
protected virtual_document: VirtualDocument;
protected connection: LSPConnection;
protected status_message: StatusMessage;
protected adapter: WidgetAdapter<IDocumentWidget>;
protected console: ILSPLogConsole;
protected trans: TranslationBundle;
get settings(): IFeatureSettings<any> | undefined {
return this.feature.settings;
}
get lab_integration() {
return this.feature.labIntegration;
}
constructor(options: IEditorIntegrationOptions) {
this.feature = options.feature;
this.virtual_editor = options.virtual_editor as CodeMirrorVirtualEditor;
this.virtual_document = options.virtual_document;
this.connection = options.connection;
this.status_message = options.status_message;
this.adapter = options.adapter;
this.console = this.adapter.console.scope(options.feature.name);
this.trans = options.trans;
this.editor_handlers = new Map();
this.connection_handlers = new Map();
this.wrapper_handlers = new Map();
this.is_registered = false;
}
register(): void {
// register editor handlers
for (let [event_name, handler] of this.editor_handlers) {
this.virtual_editor.on(event_name, handler);
}
// register connection handlers
for (let [event_name, handler] of this.connection_handlers) {
this.connection.on(event_name, handler);
}
// register editor wrapper handlers
this.wrapper = this.virtual_editor.getWrapperElement();
for (let [event_name, handler] of this.wrapper_handlers) {
this.wrapper.addEventListener(event_name, handler);
}
this.is_registered = true;
}
remove(): void {
// unregister editor handlers
for (let [event_name, handler] of this.editor_handlers) {
this.virtual_editor.off(event_name, handler);
}
this.editor_handlers.clear();
// unregister connection handlers
for (let [event_name, handler] of this.connection_handlers) {
this.connection.off(event_name, handler);
}
this.connection_handlers.clear();
// unregister editor wrapper handlers
for (let [event_name, handler] of this.wrapper_handlers) {
this.wrapper.removeEventListener(event_name, handler);
}
this.wrapper_handlers.clear();
}
protected range_to_editor_range(
range: lsProtocol.Range,
cm_editor?: CodeMirror.Editor
): IEditorRange {
let start = PositionConverter.lsp_to_cm(range.start) as IVirtualPosition;
let end = PositionConverter.lsp_to_cm(range.end) as IVirtualPosition;
if (cm_editor == null) {
let start_in_root =
this.transform_virtual_position_to_root_position(start);
let ce_editor =
this.virtual_editor.get_editor_at_root_position(start_in_root);
cm_editor = this.virtual_editor.ce_editor_to_cm_editor.get(ce_editor)!;
}
return {
start: this.virtual_document.transform_virtual_to_editor(start)!,
end: this.virtual_document.transform_virtual_to_editor(end)!,
editor: cm_editor
};
}
protected position_from_mouse(event: MouseEvent): IRootPosition | null {
const position = this.virtual_editor.coordsChar(
{
left: event.clientX,
top: event.clientY
},
'window'
);
if (position.line === -1 && position.ch === -1) {
return null;
} else {
return position as IRootPosition;
}
}
public transform_virtual_position_to_root_position(
start: IVirtualPosition
): IRootPosition {
let ce_editor = this.virtual_document.virtual_lines.get(start.line)!.editor;
let editor_position =
this.virtual_document.transform_virtual_to_editor(start);
return this.virtual_editor.transform_from_editor_to_root(
ce_editor,
editor_position!
)!;
}
protected get_cm_editor(position: IRootPosition) {
return this.virtual_editor.get_cm_editor(position);
}
protected get_language_at(
position: IEditorPosition,
editor: CodeMirror.Editor
) {
return editor.getModeAt(position).name;
}
protected extract_last_character(change: CodeMirror.EditorChange): string {
if (change.origin === 'paste') {
let last_line = change.text[change.text.length - 1];
return last_line[last_line.length - 1];
} else {
return change.text[0][0];
}
}
protected highlight_range(
range: IEditorRange,
class_name: string
): CodeMirror.TextMarker {
return range.editor
.getDoc()
.markText(range.start, range.end, { className: class_name });
}
/**
* Does the edit cover the entire document?
*/
protected is_whole_document_edit(edit: lsProtocol.TextEdit) {
let value = this.virtual_document.value;
let lines = value.split('\n');
let range = edit.range;
let lsp_to_ce = PositionConverter.lsp_to_ce;
return (
offset_at_position(lsp_to_ce(range.start), lines) === 0 &&
offset_at_position(lsp_to_ce(range.end), lines) === value.length
);
}
protected async apply_edit(
workspaceEdit: lsProtocol.WorkspaceEdit
): Promise<IEditOutcome> {
let current_uri = this.virtual_document.document_info.uri;
// Specs: documentChanges are preferred over changes
let changes = workspaceEdit.documentChanges
? workspaceEdit.documentChanges.map(
change => change as lsProtocol.TextDocumentEdit
)
: toDocumentChanges(workspaceEdit.changes!);
let applied_changes = 0;
let edited_cells: number = 0;
let is_whole_document_edit: boolean = false;
let errors: string[] = [];
for (let change of changes) {
let uri = change.textDocument.uri;
if (!uris_equal(uri, current_uri)) {
errors.push(
`Workspace-wide edits not implemented: ${uri} != ${current_uri}`
);
} else {
is_whole_document_edit =
change.edits.length === 1 &&
this.is_whole_document_edit(change.edits[0]);
let edit: lsProtocol.TextEdit;
if (!is_whole_document_edit) {
applied_changes = 0;
let value = this.virtual_document.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 edits_by_offset = new Map<number, lsProtocol.TextEdit>();
for (let e of change.edits) {
let offset = offset_from_lsp(e.range.start, lines);
if (edits_by_offset.has(offset)) {
console.warn(
'Edits should not overlap, ignoring an overlapping edit'
);
} else {
edits_by_offset.set(offset, e);
applied_changes += 1;
}
}
// TODO make use of old_to_new_line for edits which add of remove lines:
// this is crucial to preserve cell boundaries in notebook in such cases
let old_to_new_line = new DefaultMap<number, number[]>(() => []);
let new_text = '';
let last_end = 0;
let current_old_line = 0;
let current_new_line = 0;
// going over the edits in descending order of start points:
let start_offsets = [...edits_by_offset.keys()].sort((a, b) => a - b);
for (let start of start_offsets) {
let edit = edits_by_offset.get(start)!;
let prefix = value.slice(last_end, start);
for (let i = 0; i < prefix.split('\n').length; i++) {
let new_lines = old_to_new_line.get_or_create(current_old_line);
new_lines.push(current_new_line);
current_old_line += 1;
current_new_line += 1;
}
new_text += prefix + edit.newText;
let end = offset_from_lsp(edit.range.end, lines);
let replaced_fragment = value.slice(start, end);
for (let i = 0; i < edit.newText.split('\n').length; i++) {
if (i < replaced_fragment.length) {
current_old_line += 1;
}
current_new_line += 1;
let new_lines = old_to_new_line.get_or_create(current_old_line);
new_lines.push(current_new_line);
}
last_end = end;
}
new_text += value.slice(last_end, value.length);
edit = {
range: {
start: { line: 0, character: 0 },
end: {
line: lines.length - 1,
character: lines[lines.length - 1].length
}
},
newText: new_text
};
console.assert(this.is_whole_document_edit(edit));
} else {
edit = change.edits[0];
applied_changes += 1;
}
edited_cells = this.apply_single_edit(edit);
}
}
const all_empty = changes.every(change => change.edits.length === 0);
return {
appliedChanges: applied_changes,
modifiedCells: edited_cells,
wasGranular: !is_whole_document_edit && !all_empty,
errors: errors
};
}
protected replace_fragment(
newText: string,
editor: CodeEditor.IEditor,
fragment_start: CodeMirror.Position,
fragment_end: CodeMirror.Position,
start: CodeMirror.Position,
end: CodeMirror.Position,
is_whole_document_edit = false
): number {
let document = this.virtual_document;
let newFragmentText = newText
.split('\n')
.slice(fragment_start.line - start.line, fragment_end.line - start.line)
.join('\n');
if (newFragmentText.endsWith('\n')) {
newFragmentText = newFragmentText.slice(0, -1);
}
let doc = this.virtual_editor.ce_editor_to_cm_editor.get(editor)!.getDoc();
let raw_value = doc.getValue('\n');
// extract foreign documents and substitute magics,
// as it was done when the shadow virtual document was being created
let { lines } = document.prepare_code_block({
value: raw_value,
ce_editor: editor
});
let old_value = lines.join('\n');
if (is_whole_document_edit) {
// partial edit
let cm_to_ce = PositionConverter.cm_to_ce;
let up_to_offset = offset_at_position(cm_to_ce(start), lines);
let from_offset = offset_at_position(cm_to_ce(end), lines);
newFragmentText =
old_value.slice(0, up_to_offset) +
newText +
old_value.slice(from_offset);
}
if (old_value === newFragmentText) {
return 0;
}
let new_value = document.decode_code_block(newFragmentText);
let cursor = doc.getCursor();
doc.replaceRange(
new_value,
{ line: 0, ch: 0 },
{
line: fragment_end.line - fragment_start.line + 1,
ch: 0
}
);
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).
doc.setCursor(cursor, cursor.ch, { scroll: false });
// 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;
}
afterChange(change: IEditorChange, root_position: IRootPosition) {
// nothing here
}
protected apply_single_edit(edit: lsProtocol.TextEdit): number {
let document = this.virtual_document;
let applied_changes = 0;
let start = PositionConverter.lsp_to_cm(edit.range.start);
let end = PositionConverter.lsp_to_cm(edit.range.end);
let start_editor = document.get_editor_at_virtual_line(
start as IVirtualPosition
);
let end_editor = document.get_editor_at_virtual_line(
end as IVirtualPosition
);
if (start_editor !== end_editor) {
let last_editor = start_editor;
let fragment_start = start;
let fragment_end = { ...start };
let line = start.line;
let recently_replaced = false;
while (line <= end.line) {
line++;
let editor = document.get_editor_at_virtual_line({
line: line,
ch: 0
} as IVirtualPosition);
if (editor === last_editor) {
fragment_end.line = line;
fragment_end.ch = 0;
recently_replaced = false;
} else {
applied_changes += this.replace_fragment(
edit.newText,
last_editor,
fragment_start,
fragment_end,
start,
end
);
recently_replaced = true;
fragment_start = {
line: line,
ch: 0
};
fragment_end = {
line: line,
ch: 0
};
last_editor = editor;
}
}
if (!recently_replaced) {
applied_changes += this.replace_fragment(
edit.newText,
last_editor,
fragment_start,
fragment_end,
start,
end
);
}
} else {
applied_changes += this.replace_fragment(
edit.newText,
start_editor,
start,
end,
start,
end
);
}
return applied_changes;
}
}
export type CodeMirrorIntegrationConstructor = {
new (options: IEditorIntegrationOptions): CodeMirrorIntegration;
};