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; } 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; settings: FeatureSettings; constructor( settings: FeatureSettings, 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() } ]; export const JUMP_PLUGIN: JupyterFrontEndPlugin = { 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 } } } } }); } };