import type { Completion, CompletionContext, CompletionResult, } from '@codemirror/autocomplete' import { completeFromList, snippetCompletion } from '@codemirror/autocomplete' import { Facet, StateEffect, Extension, Transaction } from '@codemirror/state' import type { ViewUpdate, PluginValue, PluginSpec, ViewPlugin, } from '@codemirror/view' import { EditorView, Tooltip } from '@codemirror/view' import type { PublishDiagnosticsParams } from 'vscode-languageserver-protocol' import type * as LSP from 'vscode-languageserver-protocol' import { DiagnosticSeverity, CompletionTriggerKind, } from 'vscode-languageserver-protocol' import { URI } from 'vscode-uri' import { LanguageServerClient } from '../client' import { lspSemanticTokensEvent, lspFormatCodeEvent, relevantUpdate, } from './annotations' import { CompletionItemKindMap } from './autocomplete' import { addToken, SemanticToken } from './semantic-tokens' import { deferExecution, posToOffset, formatMarkdownContents } from './util' import { lspAutocompleteKeymapExt } from './autocomplete' import lspHoverExt from './hover' import lspFormatExt from './format' import lspIndentExt from './indent' import lspLintExt from './lint' import lspSemanticTokensExt from './semantic-tokens' const useLast = (values: readonly any[]) => values.reduce((_, v) => v, '') export const docPathFacet = Facet.define({ combine: useLast, }) export const languageId = Facet.define({ combine: useLast }) export const workspaceFolders = Facet.define< LSP.WorkspaceFolder[], LSP.WorkspaceFolder[] >({ combine: useLast }) export interface LanguageServerOptions { // We assume this is the main project directory, we are currently working in. workspaceFolders: LSP.WorkspaceFolder[] documentUri: string allowHTMLContent: boolean client: LanguageServerClient processLspNotification?: ( plugin: LanguageServerPlugin, notification: LSP.NotificationMessage ) => void changesDelay?: number } export class LanguageServerPlugin implements PluginValue { public client: LanguageServerClient private documentVersion: number private foldingRanges: LSP.FoldingRange[] | null = null private previousSemanticTokens: SemanticToken[] = [] private allowHTMLContent: boolean = true private changesDelay: number = 600 private processLspNotification?: ( plugin: LanguageServerPlugin, notification: LSP.NotificationMessage ) => void private _defferer = deferExecution((code: string) => { try { // Update the state (not the editor) with the new code. this.client.textDocumentDidChange({ textDocument: { uri: this.getDocUri(), version: this.documentVersion++, }, contentChanges: [{ text: code }], }) this.requestSemanticTokens() this.updateFoldingRanges() } catch (e) { console.error(e) } }, this.changesDelay) constructor(options: LanguageServerOptions, private view: EditorView) { this.client = options.client this.documentVersion = 0 if (options.changesDelay) { this.changesDelay = options.changesDelay } if (options.allowHTMLContent !== undefined) { this.allowHTMLContent = options.allowHTMLContent } this.client.attachPlugin(this) this.processLspNotification = options.processLspNotification this.initialize({ documentText: this.getDocText(), }) } private getDocPath(view = this.view) { return view.state.facet(docPathFacet) } private getDocText(view = this.view) { return view.state.doc.toString() } private getDocUri(view = this.view) { return URI.file(this.getDocPath(view)).toString() } private getLanguageId(view = this.view) { return view.state.facet(languageId) } update(viewUpdate: ViewUpdate) { const isRelevant = relevantUpdate(viewUpdate) if (!isRelevant.overall) { return } // If the doc didn't change we can return early. if (!viewUpdate.docChanged) { return } this.sendChange({ documentText: viewUpdate.state.doc.toString(), }) } destroy() { this.client.detachPlugin(this) } async initialize({ documentText }: { documentText: string }) { if (this.client.initializePromise) { await this.client.initializePromise } this.client.textDocumentDidOpen({ textDocument: { uri: this.getDocUri(), languageId: this.getLanguageId(), text: documentText, version: this.documentVersion, }, }) this.requestSemanticTokens() this.updateFoldingRanges() } async sendChange({ documentText }: { documentText: string }) { if (!this.client.ready) return this._defferer(documentText) } requestDiagnostics() { this.sendChange({ documentText: this.getDocText() }) } async requestHoverTooltip( view: EditorView, { line, character }: { line: number; character: number } ): Promise { if ( !this.client.ready || !this.client.getServerCapabilities().hoverProvider ) return null this.sendChange({ documentText: this.getDocText() }) const result = await this.client.textDocumentHover({ textDocument: { uri: this.getDocUri() }, position: { line, character }, }) if (!result) return null const { contents, range } = result let pos = posToOffset(view.state.doc, { line, character })! let end: number | undefined if (range) { pos = posToOffset(view.state.doc, range.start)! end = posToOffset(view.state.doc, range.end) } if (pos === null) return null const dom = document.createElement('div') dom.classList.add('documentation') dom.classList.add('hover-tooltip') dom.style.zIndex = '99999999' if (this.allowHTMLContent) dom.innerHTML = formatMarkdownContents(contents) else dom.textContent = formatMarkdownContents(contents) return { pos, end, create: (view) => ({ dom }), above: true } } async getFoldingRanges(): Promise { if ( !this.client.ready || !this.client.getServerCapabilities().foldingRangeProvider ) return null const result = await this.client.textDocumentFoldingRange({ textDocument: { uri: this.getDocUri() }, }) return result || null } async updateFoldingRanges() { const foldingRanges = await this.getFoldingRanges() if (foldingRanges === null) return // Update the folding ranges. this.foldingRanges = foldingRanges } // In the future if codemirrors foldService accepts async folding ranges // then we will not have to store these and we can call getFoldingRanges // here. foldingRange( lineStart: number, lineEnd: number ): { from: number; to: number } | null { if (this.foldingRanges === null) { return null } for (let i = 0; i < this.foldingRanges.length; i++) { const { startLine, endLine } = this.foldingRanges[i] if (startLine === lineEnd) { const range = { // Set the fold start to the end of the first line // With this, the fold will not include the first line from: startLine, to: endLine, } return range } } return null } async requestFormatting() { if ( !this.client.ready || !this.client.getServerCapabilities().documentFormattingProvider ) return null this.client.textDocumentDidChange({ textDocument: { uri: this.getDocUri(), version: this.documentVersion++, }, contentChanges: [{ text: this.getDocText() }], }) const result = await this.client.textDocumentFormatting({ textDocument: { uri: this.getDocUri() }, options: { tabSize: 2, insertSpaces: true, insertFinalNewline: true, }, }) if (!result || !result.length) return null this.view.dispatch({ changes: result.map(({ range, newText }) => ({ from: posToOffset(this.view.state.doc, range.start)!, to: posToOffset(this.view.state.doc, range.end)!, insert: newText, })), annotations: lspFormatCodeEvent, }) } async requestCompletion( context: CompletionContext, { line, character }: { line: number; character: number }, { triggerKind, triggerCharacter, }: { triggerKind: CompletionTriggerKind triggerCharacter: string | undefined } ): Promise { if ( !this.client.ready || !this.client.getServerCapabilities().completionProvider ) return null this.sendChange({ documentText: context.state.doc.toString(), }) const result = await this.client.textDocumentCompletion({ textDocument: { uri: this.getDocUri() }, position: { line, character }, context: { triggerKind, triggerCharacter, }, }) if (!result) return null const items = 'items' in result ? result.items : result let options = items.map( ({ detail, label, labelDetails, kind, textEdit, documentation, deprecated, insertText, insertTextFormat, sortText, filterText, }) => { const completion: Completion & { filterText: string sortText?: string apply: string } = { label, detail: labelDetails ? labelDetails.detail : detail, apply: label, type: kind && CompletionItemKindMap[kind].toLowerCase(), sortText: sortText ?? label, filterText: filterText ?? label, } if (documentation) { completion.info = () => { const htmlString = formatMarkdownContents(documentation) const htmlNode = document.createElement('div') htmlNode.style.display = 'contents' htmlNode.innerHTML = htmlString return { dom: htmlNode } } } if (insertText && insertTextFormat === 2) { return snippetCompletion(insertText, completion) } return completion } ) return completeFromList(options)(context) } parseSemanticTokens(view: EditorView, data: number[]) { // decode the lsp semantic token types const tokens = [] for (let i = 0; i < data.length; i += 5) { tokens.push({ deltaLine: data[i], startChar: data[i + 1], length: data[i + 2], tokenType: data[i + 3], modifiers: data[i + 4], }) } // convert the tokens into an array of {to, from, type} objects const tokenTypes = this.client.getServerCapabilities().semanticTokensProvider!.legend .tokenTypes const tokenModifiers = this.client.getServerCapabilities().semanticTokensProvider!.legend .tokenModifiers const tokenRanges: any = [] let curLine = 0 let prevStart = 0 for (let i = 0; i < tokens.length; i++) { const token = tokens[i] const tokenType = tokenTypes[token.tokenType] // get a list of modifiers const tokenModifier = [] for (let j = 0; j < tokenModifiers.length; j++) { if (token.modifiers & (1 << j)) { tokenModifier.push(tokenModifiers[j]) } } if (token.deltaLine !== 0) prevStart = 0 const tokenRange = { from: posToOffset(view.state.doc, { line: curLine + token.deltaLine, character: prevStart + token.startChar, })!, to: posToOffset(view.state.doc, { line: curLine + token.deltaLine, character: prevStart + token.startChar + token.length, })!, type: tokenType, modifiers: tokenModifier, } tokenRanges.push(tokenRange) curLine += token.deltaLine prevStart += token.startChar } // sort by from tokenRanges.sort((a: any, b: any) => a.from - b.from) return tokenRanges } async requestSemanticTokens() { if ( !this.client.ready || !this.client.getServerCapabilities().semanticTokensProvider ) { return null } const result = await this.client.textDocumentSemanticTokensFull({ textDocument: { uri: this.getDocUri() }, }) if (!result) return null const { data } = result this.previousSemanticTokens = this.parseSemanticTokens(this.view, data) const effects: StateEffect[] = this.previousSemanticTokens.map((tokenRange: any) => addToken.of(tokenRange) ) this.view.dispatch({ effects, annotations: [lspSemanticTokensEvent, Transaction.addToHistory.of(false)], }) } async processNotification(notification: LSP.NotificationMessage) { try { switch (notification.method) { case 'textDocument/publishDiagnostics': if (notification === undefined) break if (notification.params === undefined) break if (!notification.params) break const params = notification.params as PublishDiagnosticsParams if (!params) break console.log( '[lsp] [window/publishDiagnostics]', this.client.getName(), params ) // this is sometimes slower than our actual typing. this.processDiagnostics(params) break case 'window/logMessage': console.log( '[lsp] [window/logMessage]', this.client.getName(), notification.params ) break case 'window/showMessage': console.log( '[lsp] [window/showMessage]', this.client.getName(), notification.params ) break } } catch (error) { console.error(error) } // Send it to the plugin this.processLspNotification?.(this, notification) } processDiagnostics(params: PublishDiagnosticsParams) { if (params.uri !== this.getDocUri()) return const diagnostics = params.diagnostics .map(({ range, message, severity }) => ({ from: posToOffset(this.view.state.doc, range.start)!, to: posToOffset(this.view.state.doc, range.end)!, severity: ( { [DiagnosticSeverity.Error]: 'error', [DiagnosticSeverity.Warning]: 'warning', [DiagnosticSeverity.Information]: 'info', [DiagnosticSeverity.Hint]: 'info', } as const )[severity!], message, })) .filter( ({ from, to }) => from !== null && to !== null && from !== undefined && to !== undefined ) .sort((a, b) => { switch (true) { case a.from < b.from: return -1 case a.from > b.from: return 1 } return 0 }) /* This creates infighting with the others. * TODO: turn it back on when we have a better way to handle it. * this.view.dispatch({ effects: [setDiagnosticsEffect.of(diagnostics)], annotations: [lspDiagnosticsEvent, Transaction.addToHistory.of(false)], })*/ } } export class LanguageServerPluginSpec implements PluginSpec { provide(plugin: ViewPlugin): Extension { return [ lspAutocompleteKeymapExt, lspFormatExt(plugin), lspHoverExt(plugin), lspIndentExt(), lspLintExt(), lspSemanticTokensExt(), ] } }