@krassowski/jupyterlab-lsp
Version:
Language Server Protocol integration for JupyterLab
372 lines (333 loc) • 11.6 kB
text/typescript
import {
JupyterFrontEnd,
JupyterFrontEndPlugin
} from '@jupyterlab/application';
import { CodeMirrorEditor } from '@jupyterlab/codemirror';
import { URLExt } from '@jupyterlab/coreutils';
import { IDocumentManager } from '@jupyterlab/docmanager';
import { IEditorTracker } from '@jupyterlab/fileeditor';
import { INotebookTracker } from '@jupyterlab/notebook';
import { ISettingRegistry } from '@jupyterlab/settingregistry';
import { ITranslator, TranslationBundle } from '@jupyterlab/translation';
import { LabIcon } from '@jupyterlab/ui-components';
import {
CodeJumper,
FileEditorJumper,
NotebookJumper
} from '@krassowski/code-jumpers';
import { AnyLocation } from 'lsp-ws-connection/lib/types';
import jumpToSvg from '../../style/icons/jump-to.svg';
import { CodeJump as LSPJumpSettings, ModifierKey } from '../_jump_to';
import { CommandEntryPoint } from '../command_manager';
import { PositionConverter } from '../converter';
import { CodeMirrorIntegration } from '../editor_integration/codemirror';
import {
FeatureSettings,
IFeatureCommand,
IFeatureLabIntegration
} from '../feature';
import { IVirtualPosition } from '../positioning';
import { ILSPAdapterManager, ILSPFeatureManager, PLUGIN_ID } from '../tokens';
import { getModifierState, uri_to_contents_path, uris_equal } from '../utils';
export const jumpToIcon = new LabIcon({
name: 'lsp:jump-to',
svgstr: jumpToSvg
});
const jumpBackIcon = new LabIcon({
name: 'lsp:jump-back',
svgstr: jumpToSvg.replace('jp-icon3', 'lsp-icon-flip-x jp-icon3')
});
const FEATURE_ID = PLUGIN_ID + ':jump_to';
let trans: TranslationBundle;
export class CMJumpToDefinition extends CodeMirrorIntegration {
get jumper() {
return (this.feature.labIntegration as JumperLabIntegration).jumper;
}
get settings() {
return super.settings as FeatureSettings<LSPJumpSettings>;
}
protected get modifierKey(): ModifierKey {
return this.settings.composite.modifierKey;
}
register() {
this.editor_handlers.set(
'mousedown',
(virtual_editor, event: MouseEvent) => {
const { button } = event;
if (button === 0 && getModifierState(event, this.modifierKey)) {
let root_position = this.position_from_mouse(event);
if (root_position == null) {
this.console.warn(
'Could not retrieve root position from mouse event to jump to definition'
);
return;
}
let document =
virtual_editor.document_at_root_position(root_position);
let virtual_position =
virtual_editor.root_position_to_virtual_position(root_position);
this.connection
.getDefinition(virtual_position, document.document_info, false)
.then(targets => {
this.handle_jump(targets, document.document_info.uri).catch(
this.console.warn
);
})
.catch(this.console.warn);
event.preventDefault();
event.stopPropagation();
}
}
);
super.register();
}
get_uri_and_range(location_or_locations: AnyLocation) {
if (location_or_locations == null) {
return undefined;
}
// some language servers appear to return a single object
const locations = Array.isArray(location_or_locations)
? location_or_locations
: [location_or_locations];
// TODO: implement selector for multiple locations
// (like when there are multiple definitions or usages)
// could use the showHints() or completion frontend as a reference
if (locations.length === 0) {
return undefined;
}
this.console.log(
'Will jump to the first of suggested locations:',
locations
);
const location_or_link = locations[0];
if ('targetUri' in location_or_link) {
return {
uri: location_or_link.targetUri,
range: location_or_link.targetRange
};
} else if ('uri' in location_or_link) {
return {
uri: location_or_link.uri,
range: location_or_link.range
};
} else {
this.console.warn(
'Returned jump location is incorrect (no uri or targetUri):',
location_or_link
);
return undefined;
}
}
async handle_jump(location_or_locations: AnyLocation, document_uri: string) {
const target_info = this.get_uri_and_range(location_or_locations);
if (!target_info) {
this.status_message.set(trans.__('No jump targets found'), 2 * 1000);
return;
}
let { uri, range } = target_info;
let virtual_position = PositionConverter.lsp_to_cm(
range.start
) as IVirtualPosition;
if (uris_equal(uri, document_uri)) {
let editor_index = this.adapter.get_editor_index_at(virtual_position);
// if in current file, transform from the position within virtual document to the editor position:
let editor_position =
this.virtual_editor.transform_virtual_to_editor(virtual_position);
if (editor_position === null) {
this.console.warn(
'Could not jump: conversion from virtual position to editor position failed',
virtual_position
);
return;
}
let editor_position_ce = PositionConverter.cm_to_ce(editor_position);
this.console.log(`Jumping to ${editor_index}th editor of ${uri}`);
this.console.log('Jump target within editor:', editor_position_ce);
let contents_path = this.adapter.widget.context.path;
this.jumper.global_jump({
line: editor_position_ce.line,
column: editor_position.ch,
editor_index: editor_index,
is_symlink: false,
contents_path: contents_path
});
} else {
// otherwise there is no virtual document and we expect the returned position to be source position:
let source_position_ce = PositionConverter.cm_to_ce(virtual_position);
this.console.log(`Jumping to external file: ${uri}`);
this.console.log('Jump target (source location):', source_position_ce);
let jump_data = {
editor_index: 0,
line: source_position_ce.line,
column: source_position_ce.column
};
// assume that we got a relative path to a file within the project
// TODO use is_relative() or something? It would need to be not only compatible
// with different OSes but also with JupyterHub and other platforms.
// can it be resolved vs our guessed server root?
let contents_path = uri_to_contents_path(uri);
if (contents_path == null) {
if (uri.startsWith('file://')) {
contents_path = decodeURIComponent(uri.slice(7));
} else {
contents_path = decodeURIComponent(uri);
}
}
if (contents_path === null) {
this.console.warn('contents_path could not be resolved');
return;
}
try {
await this.jumper.document_manager.services.contents.get(
contents_path,
{
content: false
}
);
this.jumper.global_jump({
contents_path,
...jump_data,
is_symlink: false
});
return;
} catch (err) {
this.console.warn(err);
}
this.jumper.global_jump({
contents_path: URLExt.join('.lsp_symlink', contents_path),
...jump_data,
is_symlink: true
});
}
}
}
class JumperLabIntegration implements IFeatureLabIntegration {
private adapterManager: ILSPAdapterManager;
private jumpers: Map<string, CodeJumper>;
settings: FeatureSettings<any>;
constructor(
settings: FeatureSettings<any>,
adapterManager: ILSPAdapterManager,
notebookTracker: INotebookTracker,
documentManager: IDocumentManager,
fileEditorTracker: IEditorTracker | null
) {
this.settings = settings;
this.adapterManager = adapterManager;
this.jumpers = new Map();
if (fileEditorTracker !== null) {
fileEditorTracker.widgetAdded.connect((sender, widget) => {
let fileEditor = widget.content;
if (fileEditor.editor instanceof CodeMirrorEditor) {
let jumper = new FileEditorJumper(widget, documentManager);
this.jumpers.set(widget.id, jumper);
}
});
}
notebookTracker.widgetAdded.connect(async (sender, widget) => {
// NOTE: assuming that the default cells content factory produces CodeMirror editors(!)
let jumper = new NotebookJumper(widget, documentManager);
this.jumpers.set(widget.id, jumper);
});
}
get jumper(): CodeJumper {
let current = this.adapterManager.currentAdapter.widget.id;
return this.jumpers.get(current)!;
}
}
const COMMANDS = (trans: TranslationBundle): IFeatureCommand[] => [
{
id: 'jump-to-definition',
execute: async ({ connection, virtual_position, document, features }) => {
const jump_feature = features.get(FEATURE_ID) as CMJumpToDefinition;
const targets = await connection?.getDefinition(
virtual_position,
document.document_info,
false
);
await jump_feature.handle_jump(targets, document.document_info.uri);
},
is_enabled: ({ connection }) =>
connection ? connection.isDefinitionSupported() : false,
label: trans.__('Jump to definition'),
icon: jumpToIcon
},
{
id: 'jump-back',
execute: async ({ connection, virtual_position, document, features }) => {
const jump_feature = features.get(FEATURE_ID) as CMJumpToDefinition;
jump_feature.jumper.global_jump_back();
},
is_enabled: ({ connection }) =>
connection ? connection.isDefinitionSupported() : false,
label: trans.__('Jump back'),
icon: jumpBackIcon,
// do not attach to any of the context menus
attach_to: new Set<CommandEntryPoint>()
}
];
export const JUMP_PLUGIN: JupyterFrontEndPlugin<void> = {
id: FEATURE_ID,
requires: [
ILSPFeatureManager,
ISettingRegistry,
ILSPAdapterManager,
INotebookTracker,
IDocumentManager,
ITranslator
],
optional: [IEditorTracker],
autoStart: true,
activate: (
app: JupyterFrontEnd,
featureManager: ILSPFeatureManager,
settingRegistry: ISettingRegistry,
adapterManager: ILSPAdapterManager,
notebookTracker: INotebookTracker,
documentManager: IDocumentManager,
translator: ITranslator,
fileEditorTracker: IEditorTracker | null
) => {
const settings = new FeatureSettings(settingRegistry, FEATURE_ID);
trans = translator.load('jupyterlab-lsp');
let labIntegration = new JumperLabIntegration(
settings,
adapterManager,
notebookTracker,
documentManager,
fileEditorTracker
);
featureManager.register({
feature: {
editorIntegrationFactory: new Map([
['CodeMirrorEditor', CMJumpToDefinition]
]),
commands: COMMANDS(trans),
id: FEATURE_ID,
name: 'Jump to definition',
labIntegration: labIntegration,
settings: settings,
capabilities: {
textDocument: {
declaration: {
dynamicRegistration: true,
linkSupport: true
},
definition: {
dynamicRegistration: true,
linkSupport: true
},
typeDefinition: {
dynamicRegistration: true,
linkSupport: true
},
implementation: {
dynamicRegistration: true,
linkSupport: true
}
}
}
}
});
}
};