diff --git a/packages/jupyterlab-lsp/package.json b/packages/jupyterlab-lsp/package.json index f4cb08096..515acbc88 100644 --- a/packages/jupyterlab-lsp/package.json +++ b/packages/jupyterlab-lsp/package.json @@ -34,7 +34,7 @@ "build:labextension:dev": "jupyter labextension build --development True .", "build:lib": "tsc", "build:prod": "jlpm run build:lib && jlpm run build:labextension", - "build:schema": "jlpm build:schema-backend && jlpm build:schema-completion && jlpm build:schema-hover && jlpm build:schema-diagnostics && jlpm build:schema-syntax_highlighting && jlpm build:schema-jump_to && jlpm build:schema-signature && jlpm build:schema-highlights && jlpm build:schema-plugin && jlpm build:schema-rename", + "build:schema": "jlpm build:schema-backend && jlpm build:schema-completion && jlpm build:schema-hover && jlpm build:schema-diagnostics && jlpm build:schema-syntax_highlighting && jlpm build:schema-jump_to && jlpm build:schema-signature && jlpm build:schema-highlights && jlpm build:schema-plugin && jlpm build:schema-rename && jlpm build:schema-symbol", "build:schema-backend": "json2ts ../../python_packages/jupyter_lsp/jupyter_lsp/schema/schema.json --unreachableDefinitions | prettier --stdin-filepath _schema.d.ts > src/_schema.ts", "build:schema-plugin": "json2ts schema/plugin.json | prettier --stdin-filepath _plugin.d.ts > src/_plugin.ts", "build:schema-completion": "json2ts schema/completion.json | prettier --stdin-filepath _completion.d.ts > src/_completion.ts ", @@ -45,6 +45,7 @@ "build:schema-highlights": "json2ts schema/highlights.json | prettier --stdin-filepath _highlights.d.ts > src/_highlights.ts", "build:schema-rename": "json2ts schema/rename.json | prettier --stdin-filepath _rename.d.ts > src/_rename.ts", "build:schema-signature": "json2ts schema/signature.json | prettier --stdin-filepath _signature.d.ts > src/_signature.ts", + "build:schema-symbol": "json2ts schema/symbol.json | prettier --stdin-filepath _symbol.d.ts > src/_symbol.ts", "bundle": "npm pack .", "clean": "jlpm run clean:lib", "clean:all": "jlpm run clean:lib && jlpm run clean:labextension", diff --git a/packages/jupyterlab-lsp/schema/symbol.json b/packages/jupyterlab-lsp/schema/symbol.json new file mode 100644 index 000000000..40cae2e58 --- /dev/null +++ b/packages/jupyterlab-lsp/schema/symbol.json @@ -0,0 +1,23 @@ +{ + "jupyter.lab.setting-icon": "lsp:hover", + "jupyter.lab.setting-icon-label": "Language integration", + "title": "Code Symbols", + "description": "LSP Symbols (table of contents integration).", + "type": "object", + "properties": { + "throttlerDelay": { + "title": "Throttler delay", + "type": "number", + "default": 50, + "minimum": 0, + "description": "Number of milliseconds to delay sending out the hover request to the language server." + }, + "disable": { + "title": "Disable", + "type": "boolean", + "default": false, + "description": "Disable this feature. Requires reloading JupyterLab to apply changes." + } + }, + "jupyter.lab.shortcuts": [] +} diff --git a/packages/jupyterlab-lsp/src/features/symbol.ts b/packages/jupyterlab-lsp/src/features/symbol.ts new file mode 100644 index 000000000..c4a0091a9 --- /dev/null +++ b/packages/jupyterlab-lsp/src/features/symbol.ts @@ -0,0 +1,416 @@ +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import { IEditorExtensionRegistry } from '@jupyterlab/codemirror'; +import { DocumentRegistry, IDocumentWidget } from '@jupyterlab/docregistry'; +import { IEditorTracker, FileEditor } from '@jupyterlab/fileeditor'; +import { + ILSPFeatureManager, + ILSPDocumentConnectionManager, + WidgetLSPAdapter, + VirtualDocument +} from '@jupyterlab/lsp'; +import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { + ITableOfContentsRegistry, + TableOfContents, + TableOfContentsModel, + TableOfContentsFactory +} from '@jupyterlab/toc'; +import { Throttler } from '@lumino/polling'; +import { Widget } from '@lumino/widgets'; +import type * as lsProtocol from 'vscode-languageserver-protocol'; + +import { CodeSymbols as LSPSymbolSettings } from '../_symbol'; +import { ContextAssembler } from '../context'; +import { FeatureSettings, Feature } from '../feature'; +import { SymbolTag } from '../lsp'; +import { PLUGIN_ID } from '../tokens'; +import { BrowserConsole } from '../virtual/console'; + +/** + * Interface describing a LSP heading. + */ +interface IEditorHeading extends TableOfContents.IHeading { + /** + * LSP symbol data. + */ + symbol: lsProtocol.DocumentSymbol; +} + +/** + * Table of content model using LSP. + */ +class LSPTableOfContentsModel extends TableOfContentsModel< + IEditorHeading, + IDocumentWidget +> { + constructor( + protected widget: IDocumentWidget, + protected symbolFeature: SymbolFeature, + protected connectionManager: ILSPDocumentConnectionManager, + configuration?: TableOfContents.IConfig + ) { + super(widget, configuration); + } + + /** + * Type of document supported by the model. + * + * #### Notes + * A `data-document-type` attribute with this value will be set + * on the tree view `.jp-TableOfContents-content[data-document-type="..."]` + */ + get documentType(): string { + return 'lsp'; + } + + /** + * List of configuration options supported by the model. + */ + get supportedOptions(): (keyof TableOfContents.IConfig)[] { + return ['maximalDepth', 'numberHeaders']; + } + + /** + * Produce the headings for a document. + * + * @returns The list of new headings or `null` if nothing needs to be updated. + */ + protected async getHeadings(): Promise { + if (!this.isActive) { + return null; + } + + const adapter = [...this.connectionManager.adapters.values()].find( + adapter => adapter.widget.node.contains(this.widget.node) + ); + if (!adapter?.virtualDocument) { + return null; + } + + const headings = new Array(); + + const symbols = await this.symbolFeature.getSymbols.invoke( + adapter, + adapter.virtualDocument + ); + if (!symbols) { + return null; + } + + const processBreadthFirst = ( + elements: lsProtocol.DocumentSymbol[], + level = 1 + ) => { + for (const symbol of elements) { + headings.push({ + text: symbol.name, + level, + symbol + }); + if (symbol.children) { + processBreadthFirst(symbol.children, level + 1); + } + } + }; + processBreadthFirst(symbols); + + return headings; + } +} + +class LSPEditorTableOfContentsFactory extends TableOfContentsFactory< + IDocumentWidget, + IEditorHeading +> { + constructor( + tracker: IEditorTracker, + protected symbolFeature: SymbolFeature, + protected connectionManager: ILSPDocumentConnectionManager + ) { + super(tracker); + } + /** + * Whether the factory can handle the widget or not. + * + * @param widget - widget + * @returns boolean indicating a ToC can be generated + */ + isApplicable(widget: Widget): boolean { + const isApplicable = super.isApplicable(widget); + + if (isApplicable) { + return this.symbolFeature.isApplicable(widget); + } + return false; + } + + /** + * Create a new table of contents model for the widget + * + * @param widget - widget + * @param configuration - Table of contents configuration + * @returns The table of contents model + */ + createNew( + widget: IDocumentWidget, + configuration?: TableOfContents.IConfig + ): TableOfContentsModel< + IEditorHeading, + IDocumentWidget + > { + const model = super.createNew(widget, configuration); + + const onActiveHeadingChanged = ( + model: TableOfContentsModel< + IEditorHeading, + IDocumentWidget + >, + heading: IEditorHeading | null + ) => { + if (heading) { + widget.content.editor.setCursorPosition({ + line: heading.symbol.selectionRange.start.line, + column: heading.symbol.selectionRange.start.character + }); + } + }; + + model.activeHeadingChanged.connect(onActiveHeadingChanged); + widget.disposed.connect(() => { + model.activeHeadingChanged.disconnect(onActiveHeadingChanged); + }); + + return model; + } + + /** + * Create a new table of contents model for the widget + * + * @param widget - widget + * @param configuration - Table of contents configuration + * @returns The table of contents model + */ + protected _createNew( + widget: IDocumentWidget, + configuration?: TableOfContents.IConfig + ): LSPTableOfContentsModel { + return new LSPTableOfContentsModel( + widget, + this.symbolFeature, + this.connectionManager, + configuration + ); + } +} +function isSymbolInformationArray( + response: lsProtocol.DocumentSymbol[] | lsProtocol.SymbolInformation[] +): response is lsProtocol.SymbolInformation[] { + return ( + response.length > 0 && + !!(response[0] as lsProtocol.SymbolInformation).location + ); +} + +export class SymbolFeature extends Feature { + readonly capabilities: lsProtocol.ClientCapabilities = { + textDocument: { + documentSymbol: { + dynamicRegistration: true, + tagSupport: { + valueSet: [SymbolTag.Deprecated] + }, + hierarchicalDocumentSymbolSupport: true + } + } + }; + readonly id = SymbolFeature.id; + + protected console = new BrowserConsole().scope('Symbol'); + protected settings: FeatureSettings; + protected cache: WeakMap, lsProtocol.DocumentSymbol>; + protected contextAssembler: ContextAssembler; + public getSymbols: Throttler< + lsProtocol.DocumentSymbol[] | null, + void, + [WidgetLSPAdapter, VirtualDocument] + >; + + constructor(options: SymbolFeature.IOptions) { + super(options); + this.settings = options.settings; + this.contextAssembler = options.contextAssembler; + const { tocRegistry, editorTracker } = options; + this.connectionManager = options.connectionManager; + + if (tocRegistry && editorTracker) { + tocRegistry.add( + new LSPEditorTableOfContentsFactory( + editorTracker, + this, + this.connectionManager + ) + ); + } + + this.cache = new WeakMap(); + + this.getSymbols = this.createThrottler(); + + this.settings.changed.connect(() => { + this.getSymbols = this.createThrottler(); + }); + } + + isApplicable(widget: Widget): boolean { + const adapter = [...this.connectionManager.adapters.values()].find( + adapter => adapter.widget.node.contains(widget.node) + ); + if (!adapter?.virtualDocument) { + return false; + } + const connection = this.connectionManager.connections.get( + adapter.virtualDocument.uri + )!; + + if ( + !( + connection.isReady && + connection.serverCapabilities.documentSymbolProvider + ) + ) { + return false; + } + return true; + } + + protected createThrottler() { + return new Throttler< + lsProtocol.DocumentSymbol[] | null, + void, + [WidgetLSPAdapter, VirtualDocument] + >(this._getSymbols.bind(this), { + limit: this.settings.composite.throttlerDelay || 0, + edge: 'trailing' + }); + } + + private async _getSymbols( + adapter: WidgetLSPAdapter, + virtualDocument: VirtualDocument + ): Promise { + // TODO: return from cache + await adapter.ready; + + const connection = this.connectionManager.connections.get( + virtualDocument.uri + )!; + + if ( + !( + connection.isReady && + connection.serverCapabilities.documentSymbolProvider + ) + ) { + return null; + } + + // for popup use + // 'workspace/symbol' + + const response = await connection.clientRequests[ + 'textDocument/documentSymbol' + ].request({ + textDocument: { + uri: virtualDocument.documentInfo.uri + } + }); + // TODO: for some reason after reloading JupyterLab server errors out + // unless a file in project gets opened. This may indicate that open + // notifications are not sent out properly. + return this._handleSymbols(response); + } + + private _handleSymbols( + response: + | lsProtocol.DocumentSymbol[] + | lsProtocol.SymbolInformation[] + | null + ): lsProtocol.DocumentSymbol[] | null { + if (!response) { + return null; + } + if (isSymbolInformationArray(response)) { + return response.map(s => { + return { + name: s.name, + kind: s.kind, + tags: s.tags, + deprecated: s.deprecated, + range: s.location.range, + selectionRange: s.location.range + }; + }); + } + return response; + } +} + +export namespace SymbolFeature { + export interface IOptions extends Feature.IOptions { + settings: FeatureSettings; + renderMimeRegistry: IRenderMimeRegistry; + editorExtensionRegistry: IEditorExtensionRegistry; + contextAssembler: ContextAssembler; + tocRegistry: ITableOfContentsRegistry | null; + editorTracker: IEditorTracker | null; + } + export const id = PLUGIN_ID + ':symbol'; +} + +export const SYMBOL_PLUGIN: JupyterFrontEndPlugin = { + id: SymbolFeature.id, + requires: [ + ILSPFeatureManager, + ISettingRegistry, + IRenderMimeRegistry, + IEditorExtensionRegistry, + ILSPDocumentConnectionManager + ], + optional: [ITableOfContentsRegistry, IEditorTracker], + autoStart: true, + activate: async ( + app: JupyterFrontEnd, + featureManager: ILSPFeatureManager, + settingRegistry: ISettingRegistry, + renderMimeRegistry: IRenderMimeRegistry, + editorExtensionRegistry: IEditorExtensionRegistry, + connectionManager: ILSPDocumentConnectionManager, + tocRegistry: ITableOfContentsRegistry | null, + editorTracker: IEditorTracker | null + ) => { + const contextAssembler = new ContextAssembler({ app, connectionManager }); + const settings = new FeatureSettings( + settingRegistry, + PLUGIN_ID + ':hover' + // SymbolFeature.id + ); + await settings.ready; + if (settings.composite.disable) { + return; + } + const feature = new SymbolFeature({ + settings, + renderMimeRegistry, + editorExtensionRegistry, + connectionManager, + contextAssembler, + tocRegistry, + editorTracker + }); + featureManager.register(feature); + } +}; diff --git a/packages/jupyterlab-lsp/src/index.ts b/packages/jupyterlab-lsp/src/index.ts index c736aa5d8..196a4e414 100644 --- a/packages/jupyterlab-lsp/src/index.ts +++ b/packages/jupyterlab-lsp/src/index.ts @@ -44,6 +44,7 @@ import { HOVER_PLUGIN } from './features/hover'; import { JUMP_PLUGIN } from './features/jump_to'; import { RENAME_PLUGIN } from './features/rename'; import { SIGNATURE_PLUGIN } from './features/signature'; +import { SYMBOL_PLUGIN } from './features/symbol'; import { SYNTAX_HIGHLIGHTING_PLUGIN } from './features/syntax_highlighting'; import { CODE_OVERRIDES_MANAGER } from './overrides'; import { SettingsUIManager, SettingsSchemaManager } from './settings'; @@ -245,7 +246,8 @@ const DEFAULT_FEATURES: JupyterFrontEndPlugin[] = [ RENAME_PLUGIN, HIGHLIGHTS_PLUGIN, DIAGNOSTICS_PLUGIN, - SYNTAX_HIGHLIGHTING_PLUGIN + SYNTAX_HIGHLIGHTING_PLUGIN, + SYMBOL_PLUGIN ]; const plugins: JupyterFrontEndPlugin[] = [ LOG_CONSOLE, diff --git a/packages/jupyterlab-lsp/src/lsp.ts b/packages/jupyterlab-lsp/src/lsp.ts index e461e1d4d..7ecbf18ac 100644 --- a/packages/jupyterlab-lsp/src/lsp.ts +++ b/packages/jupyterlab-lsp/src/lsp.ts @@ -18,6 +18,10 @@ export enum DiagnosticTag { Deprecated = 2 } +export enum SymbolTag { + Deprecated = 1 +} + export enum CompletionItemTag { Deprecated = 1 }