UNPKG

@jupyter-lsp/jupyterlab-lsp

Version:

Language Server Protocol integration for JupyterLab

503 lines 21.7 kB
import { EditorView } from '@codemirror/view'; import { EditorExtensionRegistry, IEditorLanguageRegistry, jupyterHighlightStyle } from '@jupyterlab/codemirror'; import { offsetAtPosition, positionAtOffset, ILSPFeatureManager, ILSPDocumentConnectionManager } from '@jupyterlab/lsp'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { highlightTree } from '@lezer/highlight'; import { EditorTooltipManager } from '../components/free_tooltip'; import { PositionConverter, rootPositionToVirtualPosition, editorPositionToRootPosition } from '../converter'; import { FeatureSettings, Feature } from '../feature'; import { PLUGIN_ID } from '../tokens'; import { escapeMarkdown } from '../utils'; import { BrowserConsole } from '../virtual/console'; const TOOLTIP_ID = 'signature'; const CLASS_NAME = 'lsp-signature-help'; function getMarkdown(item) { if (typeof item === 'string') { return escapeMarkdown(item); } else { if (item.kind === 'markdown') { return item.value; } else { return escapeMarkdown(item.value); } } } export function extractLead(lines, size) { // try to split after paragraph const leadLines = []; let splitOnParagraph = false; for (const line of lines.slice(0, size + 1)) { const isEmpty = line.trim() == ''; if (isEmpty) { splitOnParagraph = true; break; } leadLines.push(line); } // see if we got something which does not include Markdown formatting // (so it won't lead to broken formatting if we split after it); const leadCandidate = leadLines.join('\n'); if (splitOnParagraph && leadCandidate.search(/[\\*#[\]<>_]/g) === -1) { return { lead: leadCandidate, remainder: lines.slice(leadLines.length + 1).join('\n') }; } return null; } /** * Represent signature as a Markdown element. */ export function signatureToMarkdown(item, language, codeHighlighter, logger, activeParameterFallback, maxLinesBeforeCollapse = 4) { const activeParameter = typeof item.activeParameter !== 'undefined' ? item.activeParameter : activeParameterFallback; let markdown; let label = item.label; if (item.parameters && activeParameter != null) { if (activeParameter > item.parameters.length) { logger.error('LSP server returned wrong number for activeSignature for: ', item); markdown = '```' + (language === null || language === void 0 ? void 0 : language.name) + '\n' + label + '\n```'; } else { const parameter = item.parameters[activeParameter]; markdown = codeHighlighter(label, parameter, language); } } else { markdown = '```' + (language === null || language === void 0 ? void 0 : language.name) + '\n' + label + '\n```'; } let details = ''; if (item.documentation) { if (typeof item.documentation === 'string' || item.documentation.kind === 'plaintext') { const plainTextDocumentation = typeof item.documentation === 'string' ? item.documentation : item.documentation.value; // TODO: make use of the MarkupContent object instead for (let line of plainTextDocumentation.split('\n')) { if (line.trim() === item.label.trim()) { continue; } details += getMarkdown(line) + '\n'; } } else { if (item.documentation.kind !== 'markdown') { logger.warn('Unknown MarkupContent kind:', item.documentation.kind); } details += item.documentation.value; } } else if (item.parameters) { details += '\n\n' + item.parameters .filter(parameter => parameter.documentation) .map(parameter => '- ' + getMarkdown(parameter.documentation)) .join('\n'); } if (details) { const lines = details.trim().split('\n'); if (lines.length > maxLinesBeforeCollapse) { const split = extractLead(lines, maxLinesBeforeCollapse); if (split) { details = split.lead + '\n<details>\n' + split.remainder + '\n</details>'; } else { details = '<details>\n' + details + '\n</details>'; } } markdown += '\n\n' + details; } else { markdown += '\n'; } return markdown; } export function highlightCode(source, parameter, language) { const pre = document.createElement('pre'); const code = document.createElement('code'); pre.appendChild(code); code.className = 'cm-s-jupyter' + language ? `language-${language === null || language === void 0 ? void 0 : language.name}` : ''; const substring = typeof parameter.label === 'string' ? parameter.label : source.slice(parameter.label[0], parameter.label[1]); const start = source.indexOf(substring); const end = start + substring.length; if (!language) { code.innerText = source; } else { runMode(source, language, (token, className, from, to) => { let populated = false; // In CodeMirror6 variables are not necessarily tokenized, // we need to split them manually if (from <= end && start <= to) { const a = Math.max(start, from); const b = Math.min(to, end); if (a != b) { const prefix = source.slice(from, a); const content = source.slice(a, b); const suffix = source.slice(b, to); const mark = document.createElement('mark'); if (className) { mark.className = className; } mark.appendChild(document.createTextNode(content)); code.appendChild(document.createTextNode(prefix)); code.appendChild(mark); code.appendChild(document.createTextNode(suffix)); populated = true; } } if (!populated) { if (className) { const element = document.createElement('span'); element.classList.add(className); element.textContent = token; code.appendChild(element); } else { code.appendChild(document.createTextNode(token)); } } }); } return pre.outerHTML; } function extractLastCharacter(changes) { // TODO test with pasting, maybe rewrite to retrieve based on cursor position. let last = ''; changes.iterChanges((fromA, toA, fromB, toB, inserted) => { last = inserted.sliceString(-1); }); return last ? last[0] : ''; } function runMode(source, language, callback) { const tree = language.parser.parse(source); let pos = 0; highlightTree(tree, jupyterHighlightStyle, (from, to, token) => { if (from > pos) { callback(source.slice(pos, from), null, pos, from); } callback(source.slice(from, to), token, from, to); pos = to; }); if (pos != tree.length) { callback(source.slice(pos, tree.length), null, pos, tree.length); } } export class SignatureFeature extends Feature { constructor(options) { super(options); this.id = SignatureFeature.id; this.capabilities = { textDocument: { signatureHelp: { dynamicRegistration: true, signatureInformation: { documentationFormat: ['markdown', 'plaintext'] } } } }; this.console = new BrowserConsole().scope('Signature'); this.settings = options.settings; this.tooltip = new EditorTooltipManager(options.renderMimeRegistry); this.languageRegistry = options.languageRegistry; this.extensionFactory = { name: 'lsp:codeSignature', factory: factoryOptions => { const { editor: editorAccessor, widgetAdapter: adapter } = factoryOptions; const updateListener = EditorView.updateListener.of(viewUpdate => { const editor = editorAccessor.getEditor(); if (!editor) { // see https://github.com/jupyter-lsp/jupyterlab-lsp/issues/984 // TODO: should not be needed once https://github.com/jupyterlab/jupyterlab/pull/14920 is in return; } // TODO: or should it come from viewUpdate instead?! // especially on copy paste this can be problematic. const position = editor.getCursorPosition(); const editorPosition = PositionConverter.ce_to_cm(position); // Delay handling by moving on top of the stack // so that virtual document is updated. setTimeout(() => { // be careful: updateListener also fires after blur, so we // need to carefully check what changed to avoid invalidating // user clicking on the hover box. if (viewUpdate.docChanged) { this.afterChange(viewUpdate.changes, adapter, editorPosition).catch(this.console.warn); } else if (viewUpdate.selectionSet) { this.onCursorActivity(adapter, editorPosition).catch(this.console.warn); } }, 0); }); const focusListener = EditorView.domEventHandlers({ focus: () => { // TODO // this.onCursorActivity() }, blur: event => { this.onBlur(event); } }); return EditorExtensionRegistry.createImmutableExtension([ updateListener, focusListener ]); } }; } get _closeCharacters() { if (!this.settings) { return []; } return this.settings.composite.closeCharacters; } onBlur(event) { // hide unless the focus moved to the signature itself // (allowing user to select/copy from signature) const target = event.relatedTarget; if (this.isSignatureShown() && (target ? target.closest('.' + CLASS_NAME) === null : true)) { this._removeTooltip(); } } async onCursorActivity(adapter, newEditorPosition) { if (!this.isSignatureShown()) { return; } const initialPosition = this.tooltip.position; if (newEditorPosition.line === initialPosition.line && newEditorPosition.ch < initialPosition.ch) { // close tooltip if receded beyond starting position this._removeTooltip(); } else { // otherwise, update the signature as the active parameter could have changed, // or the server may want us to close the tooltip await this._requestSignature(adapter, newEditorPosition, initialPosition); } } getMarkupForSignatureHelp(response, language) { let signatures = new Array(); if (response.activeSignature != null) { if (response.activeSignature >= response.signatures.length) { this.console.error('LSP server returned wrong number for activeSignature for: ', response); } else { const item = response.signatures[response.activeSignature]; return { kind: 'markdown', value: this.signatureToMarkdown(item, language, response.activeParameter) }; } } response.signatures.forEach(item => { let markdown = this.signatureToMarkdown(item, language); signatures.push(markdown); }); return { kind: 'markdown', value: signatures.join('\n\n') }; } /** * Represent signature as a Markdown element. */ signatureToMarkdown(item, language, activeParameterFallback) { return signatureToMarkdown(item, language, highlightCode, this.console, activeParameterFallback, this.settings.composite.maxLines); } _removeTooltip() { this.tooltip.remove(); } _hideTooltip() { this.tooltip.hide(); } handleSignature(response, adapter, positionAtRequest, displayPosition = null) { var _a, _b, _c; this.console.debug('Signature received', response); // TODO: this might wrong connection! // we need to find the correct documentAtRootPosition const virtualDocument = adapter.virtualDocument; const connection = this.connectionManager.connections.get(virtualDocument.uri); const signatureCharacters = (_b = (_a = connection.serverCapabilities.signatureHelpProvider) === null || _a === void 0 ? void 0 : _a.triggerCharacters) !== null && _b !== void 0 ? _b : []; if (response === null) { // do not hide on undefined as it simply indicates that no new info is available // (null means close, undefined means no update, response means update) this._removeTooltip(); } else if (response) { this._hideTooltip(); } if (!this.signatureCharacter || !response || !response.signatures.length) { if (response) { this._removeTooltip(); } this.console.debug('Ignoring signature response: cursor lost or response empty'); return; } // TODO: helper? const editorAccessor = adapter.activeEditor; const editor = editorAccessor.getEditor(); const pos = editor.getCursorPosition(); const editorPosition = PositionConverter.ce_to_cm(pos); // TODO should I just shove it into Feature class and have an adapter getter in there? const rootPosition = editorPositionToRootPosition(adapter, editorAccessor, editorPosition); if (!rootPosition) { this.console.warn('Signature failed: could not map editor position to root position.'); this._removeTooltip(); return; } // if the cursor advanced in the same line, the previously retrieved signature may still be useful // if the line changed or cursor moved backwards then no reason to keep the suggestions if (positionAtRequest.line != rootPosition.line || rootPosition.ch < positionAtRequest.ch) { this.console.debug('Ignoring signature response: cursor has receded or changed line'); this._removeTooltip(); return; } //const virtualPosition = rootPositionToVirtualPosition(adapter, rootPosition); //let editorAccessor = adapter.editors[adapter.getEditorIndexAt(virtualPosition)].ceEditor; //const editor = editorAccessor.getEditor(); if (!editor) { this.console.debug('Ignoring signature response: the corresponding editor is not loaded'); return; } if (!editor.hasFocus()) { this.console.debug('Ignoring signature response: the corresponding editor lost focus'); this._removeTooltip(); return; } const editorLanguage = this.languageRegistry.findByMIME(editor.model.mimeType); const language = (_c = editorLanguage === null || editorLanguage === void 0 ? void 0 : editorLanguage.support) === null || _c === void 0 ? void 0 : _c.language; let markup = this.getMarkupForSignatureHelp(response, language); this.console.debug('Signature will be shown', language, markup, rootPosition, response); if (displayPosition === null) { // try to find last occurrence of trigger character to position the tooltip const content = editor.model.sharedModel.getSource(); const lines = content.split('\n'); const offset = offsetAtPosition(PositionConverter.cm_to_ce(editorPosition), lines); // maybe? // const offset = cm_editor.getOffsetAt(PositionConverter.cm_to_ce(editorPosition)); const subset = content.substring(0, offset); const lastTriggerCharacterOffset = Math.max(...signatureCharacters.map(character => subset.lastIndexOf(character))); if (lastTriggerCharacterOffset !== -1) { displayPosition = PositionConverter.ce_to_cm(positionAtOffset(lastTriggerCharacterOffset, lines)); } else { displayPosition = editorPosition; } } this.tooltip.showOrCreate({ markup, position: displayPosition, id: TOOLTIP_ID, ceEditor: editor, adapter: adapter, className: CLASS_NAME, tooltip: { privilege: 'forceAbove', // do not move the tooltip to match the token to avoid drift of the // tooltip due the simplicity of token matching rules; instead we keep // the position constant manually via `displayPosition`. alignment: undefined, hideOnKeyPress: false } }); } isSignatureShown() { return this.tooltip.isShown(TOOLTIP_ID); } async afterChange(change, adapter, editorPosition) { var _a, _b; const lastCharacter = extractLastCharacter(change); const isSignatureShown = this.isSignatureShown(); let previousPosition = null; await adapter.updateFinished; if (isSignatureShown) { previousPosition = this.tooltip.position; if (this._closeCharacters.includes(lastCharacter)) { // remove just in case but do not short-circuit in case if we need to re-trigger this._removeTooltip(); } } // TODO: use connection for virtual document from root position! const virtualDocument = adapter.virtualDocument; if (!virtualDocument) { this.console.warn('Could not access virtual document'); return; } const connection = this.connectionManager.connections.get(virtualDocument.uri); if (!connection.isReady) { return; } const signatureCharacters = (_b = (_a = connection.serverCapabilities.signatureHelpProvider) === null || _a === void 0 ? void 0 : _a.triggerCharacters) !== null && _b !== void 0 ? _b : []; // only proceed if: trigger character was used or the signature is/was visible immediately before if (!(signatureCharacters.includes(lastCharacter) || isSignatureShown)) { return; } await this._requestSignature(adapter, editorPosition, previousPosition); } async _requestSignature(adapter, newEditorPosition, previousPosition) { // TODO: why would virtual document be missing? const virtualDocument = adapter.virtualDocument; const connection = this.connectionManager.connections.get(virtualDocument.uri); if (!(connection.isReady && connection.serverCapabilities.signatureHelpProvider)) { return; } // TODO: why missing const rootPosition = virtualDocument.transformFromEditorToRoot(adapter.activeEditor, newEditorPosition); this.signatureCharacter = rootPosition; const virtualPosition = rootPositionToVirtualPosition(adapter, rootPosition); const help = await connection.clientRequests['textDocument/signatureHelp'].request({ position: { line: virtualPosition.line, character: virtualPosition.ch }, textDocument: { uri: virtualDocument.documentInfo.uri } }); return this.handleSignature(help, adapter, rootPosition, previousPosition); } } (function (SignatureFeature) { SignatureFeature.id = PLUGIN_ID + ':signature'; })(SignatureFeature || (SignatureFeature = {})); export const SIGNATURE_PLUGIN = { id: SignatureFeature.id, requires: [ ILSPFeatureManager, ISettingRegistry, IRenderMimeRegistry, ILSPDocumentConnectionManager, IEditorLanguageRegistry ], autoStart: true, activate: async (app, featureManager, settingRegistry, renderMimeRegistry, connectionManager, languageRegistry) => { const settings = new FeatureSettings(settingRegistry, SignatureFeature.id); await settings.ready; if (settings.composite.disable) { return; } const feature = new SignatureFeature({ settings, connectionManager, renderMimeRegistry, languageRegistry }); featureManager.register(feature); // return feature; } }; //# sourceMappingURL=signature.js.map