import {
JupyterFrontEnd,
JupyterFrontEndPlugin
} from '@jupyterlab/application';
import { ICodeMirror } from '@jupyterlab/codemirror';
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
import { ISettingRegistry } from '@jupyterlab/settingregistry';
import * as lsProtocol from 'vscode-languageserver-protocol';
import { CodeSignature as LSPSignatureSettings } from '../_signature';
import { EditorTooltipManager } from '../components/free_tooltip';
import { CodeMirrorIntegration } from '../editor_integration/codemirror';
import { FeatureSettings, IFeatureLabIntegration } from '../feature';
import { IEditorPosition, IRootPosition } from '../positioning';
import { ILogConsoleCore, ILSPFeatureManager, PLUGIN_ID } from '../tokens';
import { escapeMarkdown } from '../utils';
import { CodeMirrorVirtualEditor } from '../virtual/codemirror_editor';
import { IEditorChange } from '../virtual/editor';
const TOOLTIP_ID = 'signature';
const CLASS_NAME = 'lsp-signature-help';
function getMarkdown(item: string | lsProtocol.MarkupContent) {
if (typeof item === 'string') {
return escapeMarkdown(item);
} else {
if (item.kind === 'markdown') {
return item.value;
} else {
return escapeMarkdown(item.value);
}
}
}
interface ISplit {
lead: string;
remainder: string;
}
export function extractLead(lines: string[], size: number): ISplit | null {
// 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: lsProtocol.SignatureInformation,
language: string = '',
codeHighlighter: (
source: string,
variable: string,
language: string
) => string,
logger: ILogConsoleCore,
activeParameterFallback?: number | null,
maxLinesBeforeCollapse: number = 4
): string {
const activeParameter: number | undefined | null =
typeof item.activeParameter !== 'undefined'
? item.activeParameter
: activeParameterFallback;
let markdown: string;
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 + '\n' + label + '\n```';
} else {
const parameter = item.parameters[activeParameter];
let substring: string =
typeof parameter.label === 'string'
? parameter.label
: label.slice(parameter.label[0], parameter.label[1]);
markdown = codeHighlighter(label, substring, language);
}
} else {
markdown = '```' + language + '\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\n' + split.remainder + '\n ';
} else {
details = '\n' + details + '\n ';
}
}
markdown += '\n\n' + details;
} else {
markdown += '\n';
}
return markdown;
}
export class SignatureCM extends CodeMirrorIntegration {
protected signatureCharacter: IRootPosition;
protected _signatureCharacters: string[];
get settings() {
return super.settings as FeatureSettings;
}
get _closeCharacters(): string[] {
if (!this.settings) {
return [];
}
return this.settings.composite.closeCharacters;
}
register(): void {
this.editor_handlers.set(
'cursorActivity',
this.onCursorActivity.bind(this)
);
this.editor_handlers.set('blur', this.onBlur.bind(this));
this.editor_handlers.set('focus', this.onCursorActivity.bind(this));
super.register();
}
onBlur(virtualEditor: CodeMirrorVirtualEditor, event: FocusEvent) {
// hide unless the focus moved to the signature itself
// (allowing user to select/copy from signature)
if (
this.isSignatureShown() &&
(event.relatedTarget as Element).closest('.' + CLASS_NAME) === null
) {
this._hideTooltip();
}
}
onCursorActivity() {
if (!this.isSignatureShown()) {
return;
}
const newRootPosition = this.virtual_editor.get_cursor_position();
const previousPosition = this.lab_integration.tooltip.position;
let newEditorPosition =
this.virtual_editor.root_position_to_editor(newRootPosition);
// hide tooltip if exceeded position
if (
newEditorPosition.line === previousPosition.line &&
newEditorPosition.ch < previousPosition.ch
) {
this._hideTooltip();
} else {
// otherwise, update the signature as the active parameter could have changed,
// or the server may want us to close the tooltip
this.requestSignature(newRootPosition, previousPosition)?.catch(
this.console.warn
);
}
}
get lab_integration() {
return super.lab_integration as SignatureLabIntegration;
}
protected get_markup_for_signature_help(
response: lsProtocol.SignatureHelp,
language: string = ''
): lsProtocol.MarkupContent {
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')
};
}
protected highlightCode(source: string, variable: string, language: string) {
const pre = document.createElement('pre');
const code = document.createElement('code');
pre.appendChild(code);
code.className = `cm-s-jupyter language-${language}`;
this.lab_integration.codeMirror.CodeMirror.runMode(
source,
language,
(token: string, className: string) => {
let element: HTMLElement | Node;
if (className) {
element = document.createElement('span');
(element as HTMLElement).classList.add('cm-' + className);
element.textContent = token;
} else {
element = document.createTextNode(token);
}
if (className === 'variable' && token === variable) {
const mark = document.createElement('mark');
mark.appendChild(element);
element = mark;
}
code.appendChild(element);
}
);
return pre.outerHTML;
}
/**
* Represent signature as a Markdown element.
*/
protected signatureToMarkdown(
item: lsProtocol.SignatureInformation,
language: string,
activeParameterFallback?: number | null
): string {
return signatureToMarkdown(
item,
language,
this.highlightCode.bind(this),
this.console,
activeParameterFallback,
this.settings.composite.maxLines
);
}
private _hideTooltip() {
this.lab_integration.tooltip.remove();
}
private handleSignature(
response: lsProtocol.SignatureHelp,
position_at_request: IRootPosition,
display_position: IEditorPosition | null = null
) {
this.console.log('Signature received', response);
if (response || response === null) {
// do not hide on undefined as it simply indicates that no new info is available
// (null means close, response means update)
this._hideTooltip();
}
if (!this.signatureCharacter || !response || !response.signatures.length) {
this.console.debug(
'Ignoring signature response: cursor lost or response empty'
);
return;
}
let root_position = this.virtual_editor.get_cursor_position();
// 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 (
position_at_request.line != root_position.line ||
root_position.ch < position_at_request.ch
) {
this.console.debug(
'Ignoring signature response: cursor has receded or changed line'
);
}
let cm_editor = this.get_cm_editor(root_position);
if (!cm_editor.hasFocus()) {
this.console.debug(
'Ignoring signature response: the corresponding editor lost focus'
);
return;
}
let editor_position =
this.virtual_editor.root_position_to_editor(root_position);
let language = this.get_language_at(editor_position, cm_editor);
let markup = this.get_markup_for_signature_help(response, language);
this.console.log(
'Signature will be shown',
language,
markup,
root_position,
response
);
this.lab_integration.tooltip.create({
markup,
position: display_position === null ? editor_position : display_position,
id: TOOLTIP_ID,
ce_editor: this.virtual_editor.find_ce_editor(cm_editor),
adapter: this.adapter,
className: CLASS_NAME,
tooltip: {
privilege: 'forceAbove',
alignment: 'start',
hideOnKeyPress: false
}
});
}
get signatureCharacters() {
if (!this._signatureCharacters?.length) {
this._signatureCharacters =
this.connection.getLanguageSignatureCharacters();
}
return this._signatureCharacters;
}
protected isSignatureShown() {
return this.lab_integration.tooltip.isShown(TOOLTIP_ID);
}
afterChange(change: IEditorChange, root_position: IRootPosition) {
let last_character = this.extract_last_character(change);
const isSignatureShown = this.isSignatureShown();
let previousPosition: IEditorPosition | null = null;
if (isSignatureShown) {
previousPosition = this.lab_integration.tooltip.position;
if (this._closeCharacters.includes(last_character)) {
// remove just in case but do not short-circuit in case if we need to re-trigger
this._hideTooltip();
}
}
// only proceed if: trigger character was used or the signature is/was visible immediately before
if (
!(this.signatureCharacters.includes(last_character) || isSignatureShown)
) {
return;
}
this.requestSignature(root_position, previousPosition)?.catch(
this.console.warn
);
}
private requestSignature(
root_position: IRootPosition,
previousPosition: IEditorPosition | null
) {
if (
!(
this.connection.isReady &&
this.connection.serverCapabilities?.signatureHelpProvider
)
) {
return;
}
this.signatureCharacter = root_position;
let virtual_position =
this.virtual_editor.root_position_to_virtual_position(root_position);
return this.connection.clientRequests['textDocument/signatureHelp']
.request({
position: {
line: virtual_position.line,
character: virtual_position.ch
},
textDocument: {
uri: this.virtual_document.document_info.uri
}
})
.then(help =>
this.handleSignature(help, root_position, previousPosition)
);
}
}
class SignatureLabIntegration implements IFeatureLabIntegration {
tooltip: EditorTooltipManager;
settings: FeatureSettings;
constructor(
app: JupyterFrontEnd,
settings: FeatureSettings,
renderMimeRegistry: IRenderMimeRegistry,
public codeMirror: ICodeMirror
) {
this.tooltip = new EditorTooltipManager(renderMimeRegistry);
}
}
const FEATURE_ID = PLUGIN_ID + ':signature';
export const SIGNATURE_PLUGIN: JupyterFrontEndPlugin = {
id: FEATURE_ID,
requires: [
ILSPFeatureManager,
ISettingRegistry,
IRenderMimeRegistry,
ICodeMirror
],
autoStart: true,
activate: (
app: JupyterFrontEnd,
featureManager: ILSPFeatureManager,
settingRegistry: ISettingRegistry,
renderMimeRegistry: IRenderMimeRegistry,
codeMirror: ICodeMirror
) => {
const settings = new FeatureSettings(
settingRegistry,
FEATURE_ID
);
const labIntegration = new SignatureLabIntegration(
app,
settings,
renderMimeRegistry,
codeMirror
);
featureManager.register({
feature: {
editorIntegrationFactory: new Map([['CodeMirrorEditor', SignatureCM]]),
id: FEATURE_ID,
name: 'LSP Function signature',
labIntegration: labIntegration,
settings: settings,
capabilities: {
textDocument: {
signatureHelp: {
dynamicRegistration: true,
signatureInformation: {
documentationFormat: ['markdown', 'plaintext']
}
}
}
}
}
});
}
};