@krassowski/jupyterlab-lsp
Version:
Language Server Protocol integration for JupyterLab
627 lines (557 loc) • 19.1 kB
text/typescript
import {
JupyterFrontEnd,
JupyterFrontEndPlugin
} from '@jupyterlab/application';
import { CodeEditor } from '@jupyterlab/codeeditor';
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
import { ISettingRegistry } from '@jupyterlab/settingregistry';
import { LabIcon } from '@jupyterlab/ui-components';
import { Throttler } from '@lumino/polling';
import type * as CodeMirror from 'codemirror';
import type * as lsProtocol from 'vscode-languageserver-protocol';
import hoverSvg from '../../style/icons/hover.svg';
import { CodeHover as LSPHoverSettings, ModifierKey } from '../_hover';
import { EditorTooltipManager, FreeTooltip } from '../components/free_tooltip';
import { PositionConverter } from '../converter';
import {
CodeMirrorIntegration,
IEditorRange
} from '../editor_integration/codemirror';
import {
FeatureSettings,
IEditorIntegrationOptions,
IFeatureLabIntegration
} from '../feature';
import { IRootPosition, IVirtualPosition, is_equal } from '../positioning';
import { ILSPFeatureManager, PLUGIN_ID } from '../tokens';
import { getModifierState } from '../utils';
import { VirtualDocument } from '../virtual/document';
import { IEditorChange } from '../virtual/editor';
export const hoverIcon = new LabIcon({
name: 'lsp:hover',
svgstr: hoverSvg
});
interface IResponseData {
response: lsProtocol.Hover;
document: VirtualDocument;
editor_range: IEditorRange;
ce_editor: CodeEditor.IEditor;
}
/**
* Check whether mouse is close to given element (within a specified number of pixels)
* @param what target element
* @param who mouse event determining position and target
* @param cushion number of pixels on each side defining "closeness" boundary
*/
function isCloseTo(what: HTMLElement, who: MouseEvent, cushion = 50): boolean {
const target = who.type === 'mouseleave' ? who.relatedTarget : who.target;
if (what === target || what.contains(target as HTMLElement)) {
return true;
}
const whatRect = what.getBoundingClientRect();
return !(
who.x < whatRect.left - cushion ||
who.x > whatRect.right + cushion ||
who.y < whatRect.top - cushion ||
who.y > whatRect.bottom + cushion
);
}
class ResponseCache {
protected _data: Array<IResponseData>;
get data() {
return this._data;
}
constructor(public maxSize: number) {
this._data = [];
}
store(item: IResponseData) {
const previousIndex = this._data.findIndex(
previous =>
previous.document === item.document &&
is_equal(previous.editor_range.start, item.editor_range.start) &&
is_equal(previous.editor_range.end, item.editor_range.end) &&
previous.editor_range.editor === item.editor_range.editor
);
if (previousIndex !== -1) {
this._data[previousIndex] = item;
return;
}
if (this._data.length >= this.maxSize) {
this._data.shift();
}
this._data.push(item);
}
clean() {
this._data = [];
}
}
function to_markup(
content: string | lsProtocol.MarkedString
): lsProtocol.MarkupContent {
if (typeof content === 'string') {
// coerce deprecated MarkedString to an MarkupContent; if given as a string it is markdown too,
// quote: "It is either a markdown string or a code-block that provides a language and a code snippet."
// (https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#markedString)
return {
kind: 'markdown',
value: content
};
} else {
return {
kind: 'markdown',
value: '```' + content.language + '\n' + content.value + '\n```'
};
}
}
export class HoverCM extends CodeMirrorIntegration {
protected last_hover_character: IRootPosition | null = null;
private last_hover_response: lsProtocol.Hover | null;
protected hover_marker: CodeMirror.TextMarker | null = null;
private virtual_position: IVirtualPosition;
protected cache: ResponseCache;
private debounced_get_hover: Throttler<Promise<lsProtocol.Hover | undefined>>;
private tooltip: FreeTooltip;
private _previousHoverRequest: Promise<
Promise<lsProtocol.Hover | undefined>
> | null = null;
constructor(options: IEditorIntegrationOptions) {
super(options);
}
protected get modifierKey(): ModifierKey {
return this.settings.composite.modifierKey;
}
get lab_integration() {
return super.lab_integration as HoverLabIntegration;
}
get settings() {
return super.settings as FeatureSettings<LSPHoverSettings>;
}
protected restore_from_cache(
document: VirtualDocument,
virtual_position: IVirtualPosition
): IResponseData | null {
const { line, ch } = virtual_position;
const matching_items = this.cache.data.filter(cache_item => {
if (cache_item.document !== document) {
return false;
}
let range = cache_item.response.range!;
return (
line >= range.start.line &&
line <= range.end.line &&
// need to be non-overlapping see https://github.com/jupyter-lsp/jupyterlab-lsp/issues/628
(line != range.start.line || ch > range.start.character) &&
(line != range.end.line || ch <= range.end.character)
);
});
if (matching_items.length > 1) {
this.console.warn(
'Potential hover cache malfunction: ',
virtual_position,
matching_items
);
}
return matching_items.length != 0 ? matching_items[0] : null;
}
register(): void {
this.cache = new ResponseCache(this.settings.composite.cacheSize);
this.wrapper_handlers.set('mousemove', event => {
// as CodeMirror.Editor does not support mouseleave nor mousemove,
// we simulate the mouseleave for the editor in wrapper's mousemove;
// this is used to hide the tooltip on leaving cells in notebook
this.updateUnderlineAndTooltip(event)
?.then(keep_tooltip => {
if (!keep_tooltip) {
this.maybeHideTooltip(event);
}
})
.catch(this.console.warn);
});
this.wrapper_handlers.set('mouseleave', this.onMouseLeave);
// show hover after pressing the modifier key
// TODO: when the editor (notebook or file editor) is not focused, the keydown event is not getting to us
// (probably getting captured by lab); this gives subpar experience when using hover in two editors open
// side-by-side, BUT this does not happen for mousemove which properly reads keyModifier from the event
// (so this is no too bad as most of the time the user will get the desired outcome - they just need to
// budge the mice when holding ctrl if looking at a document which is not active).
// whether the editor is focused
this.wrapper_handlers.set('keydown', this.onKeyDown);
// or just the wrapper (e.g. the notebook but no cell active)
this.editor_handlers.set('keydown', (instance, event: KeyboardEvent) =>
this.onKeyDown(event)
);
this.debounced_get_hover = this.create_throttler();
this.settings.changed.connect(() => {
this.cache.maxSize = this.settings.composite.cacheSize;
this.debounced_get_hover = this.create_throttler();
});
super.register();
}
protected onKeyDown = (event: KeyboardEvent) => {
if (
getModifierState(event, this.modifierKey) &&
this.last_hover_character !== null
) {
// does not need to be shown if it is already visible (otherwise we would be creating an identical tooltip again!)
if (this.tooltip && this.tooltip.isVisible && !this.tooltip.isDisposed) {
return;
}
const document = this.virtual_editor.document_at_root_position(
this.last_hover_character
);
let response_data = this.restore_from_cache(
document,
this.virtual_position
);
if (response_data == null) {
return;
}
event.stopPropagation();
this.handleResponse(response_data, this.last_hover_character, true);
}
};
protected onMouseLeave = (event: MouseEvent) => {
this.remove_range_highlight();
this.maybeHideTooltip(event);
};
protected maybeHideTooltip(mouseEvent: MouseEvent) {
if (
typeof this.tooltip !== 'undefined' &&
!isCloseTo(this.tooltip.node, mouseEvent)
) {
this.tooltip.dispose();
}
}
protected create_throttler() {
return new Throttler<Promise<lsProtocol.Hover | undefined>>(this.on_hover, {
limit: this.settings.composite.throttlerDelay,
edge: 'trailing'
});
}
afterChange(change: IEditorChange, root_position: IRootPosition) {
super.afterChange(change, root_position);
// reset cache on any change in the document
this.cache.clean();
this.last_hover_character = null;
this.remove_range_highlight();
}
protected on_hover = async () => {
if (
!(
this.connection.isReady &&
this.connection.serverCapabilities?.hoverProvider
)
) {
return;
}
let position = this.virtual_position;
return await this.connection.clientRequests['textDocument/hover'].request({
textDocument: {
// this might be wrong - should not it be using the specific virtual document?
uri: this.virtual_document.document_info.uri
},
position: {
line: position.line,
character: position.ch
}
});
};
protected static get_markup_for_hover(
response: lsProtocol.Hover
): lsProtocol.MarkupContent {
let contents = response.contents;
if (typeof contents === 'string') {
contents = [contents as lsProtocol.MarkedString];
}
if (!Array.isArray(contents)) {
return contents as lsProtocol.MarkupContent;
}
let markups = contents.map(to_markup);
if (markups.every(markup => markup.kind == 'plaintext')) {
return {
kind: 'plaintext',
value: markups.map(markup => markup.value).join('\n')
};
} else {
return {
kind: 'markdown',
value: markups.map(markup => markup.value).join('\n\n')
};
}
}
/**
* Underlines the word if a tooltip is available.
* Displays tooltip if asked to do so.
*
* Returns true is the tooltip was shown.
*/
public handleResponse = (
response_data: IResponseData,
root_position: IRootPosition,
show_tooltip: boolean
): boolean => {
let response = response_data.response;
// testing for object equality because the response will likely be reused from cache
if (this.last_hover_response != response) {
this.remove_range_highlight();
this.hover_marker = this.highlight_range(
response_data.editor_range,
'cm-lsp-hover-available'
);
}
this.last_hover_response = response;
if (show_tooltip) {
this.lab_integration.tooltip.remove();
const markup = HoverCM.get_markup_for_hover(response);
let editor_position =
this.virtual_editor.root_position_to_editor(root_position);
this.tooltip = this.lab_integration.tooltip.create({
markup,
position: editor_position,
ce_editor: response_data.ce_editor,
adapter: this.adapter,
className: 'lsp-hover'
});
return true;
}
return false;
};
protected is_token_empty(token: CodeMirror.Token) {
return token.string.length === 0;
// TODO || token.type.length === 0? (sometimes the underline is shown on meaningless tokens)
}
protected is_event_inside_visible(event: MouseEvent) {
let target = event.target as HTMLElement;
return target.closest('.CodeMirror-sizer') != null;
}
protected is_useful_response(response: lsProtocol.Hover) {
return (
response &&
response.contents &&
!(Array.isArray(response.contents) && response.contents.length === 0)
);
}
/**
* Returns true if the tooltip should stay.
*/
protected async _updateUnderlineAndTooltip(
event: MouseEvent
): Promise<boolean> {
const target = event.target as HTMLElement;
// if over an empty space in a line (and not over a token) then not worth checking
if (target.classList.contains('CodeMirror-line')) {
this.remove_range_highlight();
return false;
}
const show_tooltip = getModifierState(event, this.modifierKey);
// currently the events are coming from notebook panel; ideally these would be connected to individual cells,
// (only cells with code) instead, but this is more complex to implement right. In any case filtering
// is needed to determine in hovered character belongs to this virtual document
let root_position = this.position_from_mouse(event);
// happens because mousemove is attached to panel, not individual code cells,
// and because some regions of the editor (between lines) have no characters
if (root_position == null) {
this.remove_range_highlight();
return false;
}
let token = this.virtual_editor.getTokenAt(root_position);
let document = this.virtual_editor.document_at_root_position(root_position);
if (
this.is_token_empty(token) ||
document !== this.virtual_document ||
!this.is_event_inside_visible(event)
) {
this.remove_range_highlight();
return false;
}
if (
!this.last_hover_character ||
!is_equal(root_position, this.last_hover_character)
) {
let virtual_position =
this.virtual_editor.root_position_to_virtual_position(root_position);
this.virtual_position = virtual_position;
this.last_hover_character = root_position;
// if we already sent a request, maybe it already covers the are of interest?
// not harm waiting as the server won't be able to help us anyways
if (this._previousHoverRequest) {
await Promise.race([
this._previousHoverRequest,
// just in case if the request stalled, set a timeout so we do not
// get stuck indefinitely
new Promise(resolve => {
return setTimeout(resolve, 1000);
})
]);
}
let response_data = this.restore_from_cache(document, virtual_position);
if (response_data == null) {
const promise = this.debounced_get_hover.invoke();
this._previousHoverRequest = promise;
let response = await promise;
if (this._previousHoverRequest === promise) {
this._previousHoverRequest = null;
}
if (response && this.is_useful_response(response)) {
let ce_editor =
this.virtual_editor.get_editor_at_root_position(root_position);
let cm_editor =
this.virtual_editor.ce_editor_to_cm_editor.get(ce_editor)!;
let editor_range = this.get_editor_range(
response,
root_position,
token,
cm_editor
);
response_data = {
response: this.add_range_if_needed(
response,
editor_range,
ce_editor
),
document: document,
editor_range: editor_range,
ce_editor: ce_editor
};
this.cache.store(response_data);
} else {
this.remove_range_highlight();
return false;
}
}
return this.handleResponse(response_data, root_position, show_tooltip);
} else {
return true;
}
}
protected updateUnderlineAndTooltip = (event: MouseEvent) => {
try {
return this._updateUnderlineAndTooltip(event);
} catch (e) {
if (
!(
e.message === 'Cell not found in cell_line_map' ||
e.message === "Cannot read property 'string' of undefined"
)
) {
this.console.warn(e);
}
return undefined;
}
};
protected remove_range_highlight = () => {
if (this.hover_marker) {
this.hover_marker.clear();
this.hover_marker = null;
this.last_hover_response = null;
this.last_hover_character = null;
}
};
remove(): void {
this.cache.clean();
this.remove_range_highlight();
this.debounced_get_hover.dispose();
super.remove();
}
private get_editor_range(
response: lsProtocol.Hover,
position: IRootPosition,
token: CodeMirror.Token,
cm_editor: CodeMirror.Editor
): IEditorRange {
if (typeof response.range !== 'undefined') {
return this.range_to_editor_range(response.range, cm_editor);
}
// construct the range manually using the token information
let start_in_root = {
line: position.line,
ch: token.start
} as IRootPosition;
let end_in_root = {
line: position.line,
ch: token.end
} as IRootPosition;
return {
start: this.virtual_editor.root_position_to_editor(start_in_root),
end: this.virtual_editor.root_position_to_editor(end_in_root),
editor: cm_editor
};
}
private add_range_if_needed(
response: lsProtocol.Hover,
editor_range: IEditorRange,
ce_editor: CodeEditor.IEditor
): lsProtocol.Hover {
if (typeof response.range !== 'undefined') {
return response;
}
return {
...response,
range: {
start: PositionConverter.cm_to_lsp(
this.virtual_editor.root_position_to_virtual_position(
this.virtual_editor.transform_from_editor_to_root(
ce_editor,
editor_range.start
)!
)
),
end: PositionConverter.cm_to_lsp(
this.virtual_editor.root_position_to_virtual_position(
this.virtual_editor.transform_from_editor_to_root(
ce_editor,
editor_range.end
)!
)
)
}
};
}
}
class HoverLabIntegration implements IFeatureLabIntegration {
tooltip: EditorTooltipManager;
settings: FeatureSettings<any>;
constructor(
app: JupyterFrontEnd,
settings: FeatureSettings<any>,
renderMimeRegistry: IRenderMimeRegistry
) {
this.tooltip = new EditorTooltipManager(renderMimeRegistry);
}
}
const FEATURE_ID = PLUGIN_ID + ':hover';
export const HOVER_PLUGIN: JupyterFrontEndPlugin<void> = {
id: FEATURE_ID,
requires: [ILSPFeatureManager, ISettingRegistry, IRenderMimeRegistry],
autoStart: true,
activate: (
app: JupyterFrontEnd,
featureManager: ILSPFeatureManager,
settingRegistry: ISettingRegistry,
renderMimeRegistry: IRenderMimeRegistry
) => {
const settings = new FeatureSettings(settingRegistry, FEATURE_ID);
const labIntegration = new HoverLabIntegration(
app,
settings,
renderMimeRegistry
);
featureManager.register({
feature: {
editorIntegrationFactory: new Map([['CodeMirrorEditor', HoverCM]]),
id: FEATURE_ID,
name: 'LSP Hover tooltip',
labIntegration: labIntegration,
settings: settings,
capabilities: {
textDocument: {
hover: {
dynamicRegistration: true,
contentFormat: ['markdown', 'plaintext']
}
}
}
}
});
}
};