Codemirror lsp enhance (#6580)
* codemirror side Signed-off-by: Jess Frazelle <github@jessfraz.com> * codemirror actions Signed-off-by: Jess Frazelle <github@jessfraz.com> * codemirror actions Signed-off-by: Jess Frazelle <github@jessfraz.com> * code mirror now shows lint suggestions Signed-off-by: Jess Frazelle <github@jessfraz.com> * fix hanging params with test Signed-off-by: Jess Frazelle <github@jessfraz.com> * updates for signature help Signed-off-by: Jess Frazelle <github@jessfraz.com> * fix clone Signed-off-by: Jess Frazelle <github@jessfraz.com> * add tests Signed-off-by: Jess Frazelle <github@jessfraz.com> * add tests Signed-off-by: Jess Frazelle <github@jessfraz.com> * clippy Signed-off-by: Jess Frazelle <github@jessfraz.com> * clippy Signed-off-by: Jess Frazelle <github@jessfraz.com> * updates Signed-off-by: Jess Frazelle <github@jessfraz.com> * Update packages/codemirror-lsp-client/src/plugin/lsp.ts Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * z-index Signed-off-by: Jess Frazelle <github@jessfraz.com> * playwright tests Signed-off-by: Jess Frazelle <github@jessfraz.com> * updates Signed-off-by: Jess Frazelle <github@jessfraz.com> * updates Signed-off-by: Jess Frazelle <github@jessfraz.com> --------- Signed-off-by: Jess Frazelle <github@jessfraz.com> Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
This commit is contained in:
@ -14,6 +14,7 @@ interface LSPRequestMap {
|
||||
LSP.CompletionParams,
|
||||
LSP.CompletionItem[] | LSP.CompletionList | null,
|
||||
]
|
||||
'completionItem/resolve': [LSP.CompletionItem, LSP.CompletionItem]
|
||||
'textDocument/semanticTokens/full': [
|
||||
LSP.SemanticTokensParams,
|
||||
LSP.SemanticTokens,
|
||||
@ -23,6 +24,23 @@ interface LSPRequestMap {
|
||||
LSP.TextEdit[] | null,
|
||||
]
|
||||
'textDocument/foldingRange': [LSP.FoldingRangeParams, LSP.FoldingRange[]]
|
||||
'textDocument/signatureHelp': [
|
||||
LSP.SignatureHelpParams,
|
||||
LSP.SignatureHelp | null,
|
||||
]
|
||||
'textDocument/codeAction': [
|
||||
LSP.CodeActionParams,
|
||||
(LSP.Command | LSP.CodeAction)[] | null,
|
||||
]
|
||||
'textDocument/rename': [LSP.RenameParams, LSP.WorkspaceEdit | null]
|
||||
'textDocument/prepareRename': [
|
||||
LSP.PrepareRenameParams,
|
||||
LSP.Range | LSP.PrepareRenameResult | null,
|
||||
]
|
||||
'textDocument/definition': [
|
||||
LSP.DefinitionParams,
|
||||
LSP.Definition | LSP.DefinitionLink[] | null,
|
||||
]
|
||||
}
|
||||
|
||||
// Client to server
|
||||
@ -124,7 +142,7 @@ export class LanguageServerClient {
|
||||
async textDocumentSemanticTokensFull(params: LSP.SemanticTokensParams) {
|
||||
const serverCapabilities = this.getServerCapabilities()
|
||||
if (!serverCapabilities.semanticTokensProvider) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
return this.request('textDocument/semanticTokens/full', params)
|
||||
@ -133,7 +151,7 @@ export class LanguageServerClient {
|
||||
async textDocumentHover(params: LSP.HoverParams) {
|
||||
const serverCapabilities = this.getServerCapabilities()
|
||||
if (!serverCapabilities.hoverProvider) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
return await this.request('textDocument/hover', params)
|
||||
}
|
||||
@ -141,7 +159,7 @@ export class LanguageServerClient {
|
||||
async textDocumentFormatting(params: LSP.DocumentFormattingParams) {
|
||||
const serverCapabilities = this.getServerCapabilities()
|
||||
if (!serverCapabilities.documentFormattingProvider) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
return await this.request('textDocument/formatting', params)
|
||||
}
|
||||
@ -149,7 +167,7 @@ export class LanguageServerClient {
|
||||
async textDocumentFoldingRange(params: LSP.FoldingRangeParams) {
|
||||
const serverCapabilities = this.getServerCapabilities()
|
||||
if (!serverCapabilities.foldingRangeProvider) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
return await this.request('textDocument/foldingRange', params)
|
||||
}
|
||||
@ -157,10 +175,58 @@ export class LanguageServerClient {
|
||||
async textDocumentCompletion(params: LSP.CompletionParams) {
|
||||
const serverCapabilities = this.getServerCapabilities()
|
||||
if (!serverCapabilities.completionProvider) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
const response = await this.request('textDocument/completion', params)
|
||||
return response
|
||||
return await this.request('textDocument/completion', params)
|
||||
}
|
||||
|
||||
async completionItemResolve(params: LSP.CompletionItem) {
|
||||
const serverCapabilities = this.getServerCapabilities()
|
||||
if (!serverCapabilities.completionProvider) {
|
||||
return null
|
||||
}
|
||||
return await this.request('completionItem/resolve', params)
|
||||
}
|
||||
|
||||
async textDocumentSignatureHelp(params: LSP.SignatureHelpParams) {
|
||||
const serverCapabilities = this.getServerCapabilities()
|
||||
if (!serverCapabilities.signatureHelpProvider) {
|
||||
return null
|
||||
}
|
||||
return await this.request('textDocument/signatureHelp', params)
|
||||
}
|
||||
|
||||
async textDocumentCodeAction(params: LSP.CodeActionParams) {
|
||||
const serverCapabilities = this.getServerCapabilities()
|
||||
if (!serverCapabilities.codeActionProvider) {
|
||||
return null
|
||||
}
|
||||
return await this.request('textDocument/codeAction', params)
|
||||
}
|
||||
|
||||
async textDocumentRename(params: LSP.RenameParams) {
|
||||
const serverCapabilities = this.getServerCapabilities()
|
||||
if (!serverCapabilities.renameProvider) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await this.request('textDocument/rename', params)
|
||||
}
|
||||
|
||||
async textDocumentPrepareRename(params: LSP.PrepareRenameParams) {
|
||||
const serverCapabilities = this.getServerCapabilities()
|
||||
if (!serverCapabilities.renameProvider) {
|
||||
return null
|
||||
}
|
||||
return await this.request('textDocument/prepareRename', params)
|
||||
}
|
||||
|
||||
async textDocumentDefinition(params: LSP.DefinitionParams) {
|
||||
const serverCapabilities = this.getServerCapabilities()
|
||||
if (!serverCapabilities.definitionProvider) {
|
||||
return null
|
||||
}
|
||||
return await this.request('textDocument/definition', params)
|
||||
}
|
||||
|
||||
attachPlugin(plugin: LanguageServerPlugin) {
|
||||
|
||||
@ -18,7 +18,9 @@ export { Codec } from './client/codec/utils'
|
||||
export {
|
||||
lspDiagnosticsEvent,
|
||||
lspFormatCodeEvent,
|
||||
lspRenameEvent,
|
||||
lspSemanticTokensEvent,
|
||||
lspCodeActionEvent,
|
||||
} from './plugin/annotation'
|
||||
export {
|
||||
LanguageServerPlugin,
|
||||
|
||||
@ -4,9 +4,13 @@ export enum LspAnnotation {
|
||||
SemanticTokens = 'semantic-tokens',
|
||||
FormatCode = 'format-code',
|
||||
Diagnostics = 'diagnostics',
|
||||
Rename = 'rename',
|
||||
CodeAction = 'code-action',
|
||||
}
|
||||
|
||||
const lspEvent = Annotation.define<LspAnnotation>()
|
||||
export const lspSemanticTokensEvent = lspEvent.of(LspAnnotation.SemanticTokens)
|
||||
export const lspFormatCodeEvent = lspEvent.of(LspAnnotation.FormatCode)
|
||||
export const lspDiagnosticsEvent = lspEvent.of(LspAnnotation.Diagnostics)
|
||||
export const lspRenameEvent = lspEvent.of(LspAnnotation.Rename)
|
||||
export const lspCodeActionEvent = lspEvent.of(LspAnnotation.CodeAction)
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
prevSnippetField,
|
||||
startCompletion,
|
||||
} from '@codemirror/autocomplete'
|
||||
import type { CompletionContext } from '@codemirror/autocomplete'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import { Prec } from '@codemirror/state'
|
||||
@ -66,7 +67,7 @@ export default function lspAutocompleteExt(
|
||||
defaultKeymap: false,
|
||||
override: [
|
||||
async (context) => {
|
||||
const { state, pos, explicit, view } = context
|
||||
const { state, pos, view } = context
|
||||
let value = view?.plugin(plugin)
|
||||
if (!value) return null
|
||||
|
||||
@ -77,37 +78,55 @@ export default function lspAutocompleteExt(
|
||||
)
|
||||
return null
|
||||
|
||||
const line = state.doc.lineAt(pos)
|
||||
let trigKind: CompletionTriggerKind = CompletionTriggerKind.Invoked
|
||||
let trigChar: string | undefined
|
||||
if (
|
||||
!explicit &&
|
||||
value.client
|
||||
.getServerCapabilities()
|
||||
.completionProvider?.triggerCharacters?.includes(
|
||||
line.text[pos - line.from - 1]
|
||||
)
|
||||
) {
|
||||
trigKind = CompletionTriggerKind.TriggerCharacter
|
||||
trigChar = line.text[pos - line.from - 1]
|
||||
}
|
||||
if (
|
||||
trigKind === CompletionTriggerKind.Invoked &&
|
||||
!context.matchBefore(/\w+$/)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const cmpTriggers = getCompletionTriggerKind(
|
||||
context,
|
||||
value.client.getServerCapabilities().completionProvider
|
||||
?.triggerCharacters ?? []
|
||||
)
|
||||
if (!cmpTriggers) return null
|
||||
|
||||
return await value.requestCompletion(
|
||||
context,
|
||||
offsetToPos(state.doc, pos),
|
||||
{
|
||||
triggerKind: trigKind,
|
||||
triggerCharacter: trigChar,
|
||||
}
|
||||
cmpTriggers
|
||||
)
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
export function getCompletionTriggerKind(
|
||||
context: CompletionContext,
|
||||
triggerCharacters: string[],
|
||||
matchBeforePattern?: RegExp
|
||||
): {
|
||||
triggerKind: CompletionTriggerKind
|
||||
triggerCharacter?: string
|
||||
} | null {
|
||||
const { state, pos, explicit } = context
|
||||
const line = state.doc.lineAt(pos)
|
||||
|
||||
// Determine trigger kind and character
|
||||
let triggerKind: CompletionTriggerKind = CompletionTriggerKind.Invoked
|
||||
let triggerCharacter: string | undefined
|
||||
|
||||
// Check if completion was triggered by a special character
|
||||
const prevChar = line.text[pos - line.from - 1] || ''
|
||||
const isTriggerChar = triggerCharacters?.includes(prevChar)
|
||||
|
||||
if (!explicit && isTriggerChar) {
|
||||
triggerKind = CompletionTriggerKind.TriggerCharacter
|
||||
triggerCharacter = prevChar
|
||||
}
|
||||
// For manual invocation, only show completions when typing
|
||||
// Use the provided pattern or default to words, dots, commas, or slashes
|
||||
if (
|
||||
triggerKind === CompletionTriggerKind.Invoked &&
|
||||
!context.matchBefore(matchBeforePattern || /(\w+|\w+\.|\/|,)$/)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { triggerKind, triggerCharacter }
|
||||
}
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import { Prec } from '@codemirror/state'
|
||||
import type { ViewPlugin } from '@codemirror/view'
|
||||
import { keymap } from '@codemirror/view'
|
||||
|
||||
import type { LanguageServerPlugin } from './lsp'
|
||||
import { offsetToPos, showErrorMessage } from './util'
|
||||
|
||||
export default function lspGoToDefinitionExt(
|
||||
plugin: ViewPlugin<LanguageServerPlugin>
|
||||
): Extension {
|
||||
return [
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: 'F12',
|
||||
run: (view) => {
|
||||
if (!plugin) {
|
||||
return false
|
||||
}
|
||||
|
||||
const value = view.plugin(plugin)
|
||||
if (!value) return false
|
||||
|
||||
const pos = view.state.selection.main.head
|
||||
value
|
||||
.requestDefinition(view, offsetToPos(view.state.doc, pos))
|
||||
.catch((error) =>
|
||||
showErrorMessage(
|
||||
view,
|
||||
`Go to definition failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
)
|
||||
return true
|
||||
},
|
||||
},
|
||||
])
|
||||
),
|
||||
]
|
||||
}
|
||||
@ -4,6 +4,7 @@ import type {
|
||||
CompletionResult,
|
||||
} from '@codemirror/autocomplete'
|
||||
import { completeFromList, snippetCompletion } from '@codemirror/autocomplete'
|
||||
import type { Action, Diagnostic } from '@codemirror/lint'
|
||||
import { linter } from '@codemirror/lint'
|
||||
import type { Extension, StateEffect } from '@codemirror/state'
|
||||
import { Facet, Transaction } from '@codemirror/state'
|
||||
@ -24,14 +25,29 @@ import { DiagnosticSeverity } from 'vscode-languageserver-protocol'
|
||||
import { URI } from 'vscode-uri'
|
||||
|
||||
import type { LanguageServerClient } from '../client'
|
||||
import { lspFormatCodeEvent, lspSemanticTokensEvent } from './annotation'
|
||||
import {
|
||||
lspFormatCodeEvent,
|
||||
lspSemanticTokensEvent,
|
||||
lspRenameEvent,
|
||||
lspCodeActionEvent,
|
||||
} from './annotation'
|
||||
import lspAutocompleteExt, { CompletionItemKindMap } from './autocomplete'
|
||||
import lspFormatExt from './format'
|
||||
import lspHoverExt from './hover'
|
||||
import lspIndentExt from './indent'
|
||||
import type { SemanticToken } from './semantic-tokens'
|
||||
import lspSemanticTokensExt, { addToken } from './semantic-tokens'
|
||||
import { formatMarkdownContents, posToOffset } from './util'
|
||||
import {
|
||||
formatContents,
|
||||
offsetToPos,
|
||||
posToOffset,
|
||||
posToOffsetOrZero,
|
||||
showErrorMessage,
|
||||
} from './util'
|
||||
import { isArray } from '../lib/utils'
|
||||
import lspGoToDefinitionExt from './go-to-definition'
|
||||
import lspRenameExt from './rename'
|
||||
import lspSignatureHelpExt from './signature-help'
|
||||
|
||||
const useLast = (values: readonly any[]) => values.reduce((_, v) => v, '')
|
||||
export const docPathFacet = Facet.define<string, string>({
|
||||
@ -43,6 +59,25 @@ export const workspaceFolders = Facet.define<
|
||||
LSP.WorkspaceFolder[]
|
||||
>({ combine: useLast })
|
||||
|
||||
const severityMap: Record<DiagnosticSeverity, Diagnostic['severity']> = {
|
||||
[DiagnosticSeverity.Error]: 'error',
|
||||
[DiagnosticSeverity.Warning]: 'warning',
|
||||
[DiagnosticSeverity.Information]: 'info',
|
||||
[DiagnosticSeverity.Hint]: 'info',
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a definition lookup operation
|
||||
*/
|
||||
export interface DefinitionResult {
|
||||
/** URI of the target document containing the definition */
|
||||
uri: string
|
||||
/** Range in the document where the definition is located */
|
||||
range: LSP.Range
|
||||
/** Whether the definition is in a different file than the current document */
|
||||
isExternalDocument: boolean
|
||||
}
|
||||
|
||||
export interface LanguageServerOptions {
|
||||
// We assume this is the main project directory, we are currently working in.
|
||||
workspaceFolders: LSP.WorkspaceFolder[]
|
||||
@ -58,6 +93,9 @@ export interface LanguageServerOptions {
|
||||
|
||||
doSemanticTokens?: boolean
|
||||
doFoldingRanges?: boolean
|
||||
|
||||
/** Callback triggered when a go-to-definition action is performed */
|
||||
onGoToDefinition?: (result: DefinitionResult) => void
|
||||
}
|
||||
|
||||
export class LanguageServerPlugin implements PluginValue {
|
||||
@ -82,6 +120,8 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
// document.
|
||||
private sendScheduled: number | null = null
|
||||
|
||||
private onGoToDefinition: ((result: DefinitionResult) => void) | undefined
|
||||
|
||||
constructor(
|
||||
options: LanguageServerOptions,
|
||||
private view: EditorView
|
||||
@ -104,6 +144,8 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
|
||||
this.processLspNotification = options.processLspNotification
|
||||
|
||||
this.onGoToDefinition = options.onGoToDefinition
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.initialize({
|
||||
documentText: this.getDocText(),
|
||||
@ -185,8 +227,8 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
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)
|
||||
if (this.allowHTMLContent) dom.innerHTML = formatContents(contents)
|
||||
else dom.textContent = formatContents(contents)
|
||||
return { pos, end, create: (view) => ({ dom }), above: true }
|
||||
}
|
||||
|
||||
@ -317,7 +359,7 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
triggerCharacter,
|
||||
}: {
|
||||
triggerKind: CompletionTriggerKind
|
||||
triggerCharacter: string | undefined
|
||||
triggerCharacter?: string
|
||||
}
|
||||
): Promise<CompletionResult | null> {
|
||||
if (
|
||||
@ -379,8 +421,7 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
const deprecatedHtml = deprecated
|
||||
? '<p><strong>Deprecated</strong></p>'
|
||||
: ''
|
||||
const htmlString =
|
||||
deprecatedHtml + formatMarkdownContents(documentation)
|
||||
const htmlString = deprecatedHtml + formatContents(documentation)
|
||||
const htmlNode = document.createElement('div')
|
||||
htmlNode.style.display = 'contents'
|
||||
htmlNode.innerHTML = htmlString
|
||||
@ -406,6 +447,660 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
return completeFromList(options)(context)
|
||||
}
|
||||
|
||||
async requestDefinition(
|
||||
view: EditorView,
|
||||
{ line, character }: { line: number; character: number }
|
||||
) {
|
||||
if (
|
||||
!(
|
||||
this.client.ready &&
|
||||
this.client.getServerCapabilities().definitionProvider
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await this.client.textDocumentDefinition({
|
||||
textDocument: { uri: this.getDocUri() },
|
||||
position: { line, character },
|
||||
})
|
||||
|
||||
if (!result) return
|
||||
|
||||
const locations = isArray(result) ? result : [result]
|
||||
if (locations.length === 0) return
|
||||
|
||||
// For now just handle the first location
|
||||
const location = locations[0]
|
||||
if (!location) return
|
||||
const uri = 'uri' in location ? location.uri : location.targetUri
|
||||
const range = 'range' in location ? location.range : location.targetRange
|
||||
|
||||
// Check if the definition is in a different document
|
||||
const isExternalDocument = uri !== this.getDocUri()
|
||||
|
||||
// Create the definition result
|
||||
const definitionResult: DefinitionResult = {
|
||||
uri,
|
||||
range,
|
||||
isExternalDocument,
|
||||
}
|
||||
|
||||
// If it's the same document, update the selection
|
||||
if (!isExternalDocument) {
|
||||
view.dispatch(
|
||||
view.state.update({
|
||||
selection: {
|
||||
anchor: posToOffsetOrZero(view.state.doc, range.start),
|
||||
head: posToOffset(view.state.doc, range.end),
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (this.onGoToDefinition) {
|
||||
this.onGoToDefinition(definitionResult)
|
||||
}
|
||||
|
||||
return definitionResult
|
||||
}
|
||||
|
||||
async requestCodeActions(
|
||||
range: LSP.Range,
|
||||
diagnosticCodes: string[]
|
||||
): Promise<(LSP.Command | LSP.CodeAction)[] | null> {
|
||||
if (
|
||||
!(
|
||||
this.client.ready &&
|
||||
this.client.getServerCapabilities().codeActionProvider
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await this.client.textDocumentCodeAction({
|
||||
textDocument: { uri: this.getDocUri() },
|
||||
range,
|
||||
context: {
|
||||
diagnostics: [
|
||||
{
|
||||
range,
|
||||
code: diagnosticCodes[0],
|
||||
source: this.getLanguageId(),
|
||||
message: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async requestRename(
|
||||
view: EditorView,
|
||||
{ line, character }: { line: number; character: number }
|
||||
) {
|
||||
if (
|
||||
!(this.client.getServerCapabilities().renameProvider && this.client.ready)
|
||||
) {
|
||||
showErrorMessage(view, 'Rename not supported by language server')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// First check if rename is possible at this position
|
||||
const prepareResult = await this.client
|
||||
.textDocumentPrepareRename({
|
||||
textDocument: { uri: this.getDocUri() },
|
||||
position: { line, character },
|
||||
})
|
||||
.catch(() => {
|
||||
// In case prepareRename is not supported,
|
||||
// we fallback to the default implementation
|
||||
return this.prepareRenameFallback(view, {
|
||||
line,
|
||||
character,
|
||||
})
|
||||
})
|
||||
|
||||
if (!prepareResult || 'defaultBehavior' in prepareResult) {
|
||||
showErrorMessage(view, 'Cannot rename this symbol')
|
||||
return
|
||||
}
|
||||
|
||||
// Create popup input
|
||||
const popup = document.createElement('div')
|
||||
popup.className = 'cm-rename-popup'
|
||||
popup.style.cssText =
|
||||
'position: absolute; padding: 4px; background: white; border: 1px solid #ddd; box-shadow: 0 2px 8px rgba(0,0,0,.15); z-index: 99;'
|
||||
|
||||
const input = document.createElement('input')
|
||||
input.type = 'text'
|
||||
input.style.cssText =
|
||||
'width: 200px; padding: 4px; border: 1px solid #ddd;'
|
||||
|
||||
// Get current word as default value
|
||||
const range =
|
||||
'range' in prepareResult ? prepareResult.range : prepareResult
|
||||
const from = posToOffset(view.state.doc, range.start)
|
||||
if (from == null) {
|
||||
return
|
||||
}
|
||||
const to = posToOffset(view.state.doc, range.end)
|
||||
input.value = view.state.doc.sliceString(from, to)
|
||||
|
||||
popup.appendChild(input)
|
||||
|
||||
// Position the popup near the word
|
||||
const coords = view.coordsAtPos(from)
|
||||
if (!coords) return
|
||||
|
||||
popup.style.left = `${coords.left}px`
|
||||
popup.style.top = `${coords.bottom + 5}px`
|
||||
|
||||
// Handle input
|
||||
const handleRename = async () => {
|
||||
const newName = input.value.trim()
|
||||
if (!newName) {
|
||||
showErrorMessage(view, 'New name cannot be empty')
|
||||
popup.remove()
|
||||
return
|
||||
}
|
||||
|
||||
if (newName === input.defaultValue) {
|
||||
popup.remove()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const edit = await this.client.textDocumentRename({
|
||||
textDocument: { uri: this.getDocUri() },
|
||||
position: { line, character },
|
||||
newName,
|
||||
})
|
||||
|
||||
await this.applyRenameEdit(view, edit)
|
||||
} catch (error) {
|
||||
showErrorMessage(
|
||||
view,
|
||||
`Rename failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
} finally {
|
||||
popup.remove()
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleRename()
|
||||
} else if (e.key === 'Escape') {
|
||||
popup.remove()
|
||||
}
|
||||
e.stopPropagation() // Prevent editor handling
|
||||
})
|
||||
|
||||
// Handle clicks outside
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
if (!popup.contains(e.target as Node)) {
|
||||
popup.remove()
|
||||
document.removeEventListener('mousedown', handleOutsideClick)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
|
||||
// Add to DOM
|
||||
document.body.appendChild(popup)
|
||||
input.focus()
|
||||
input.select()
|
||||
} catch (error) {
|
||||
showErrorMessage(
|
||||
view,
|
||||
`Rename failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request signature help from the language server
|
||||
* @param view The editor view
|
||||
* @param position The cursor position
|
||||
* @returns A tooltip with the signature help information or null if not available
|
||||
*/
|
||||
public async requestSignatureHelp(
|
||||
view: EditorView,
|
||||
{
|
||||
line,
|
||||
character,
|
||||
}: {
|
||||
line: number
|
||||
character: number
|
||||
},
|
||||
triggerCharacter: string | undefined = undefined
|
||||
): Promise<Tooltip | null> {
|
||||
// Check if signature help is enabled
|
||||
if (
|
||||
!(
|
||||
this.client.ready &&
|
||||
this.client.getServerCapabilities().signatureHelpProvider
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
// Request signature help
|
||||
const result = await this.client.textDocumentSignatureHelp({
|
||||
textDocument: { uri: this.getDocUri() },
|
||||
position: { line, character },
|
||||
context: {
|
||||
isRetrigger: false,
|
||||
triggerKind: 1, // Invoked
|
||||
triggerCharacter,
|
||||
},
|
||||
})
|
||||
|
||||
if (!result?.signatures || result.signatures.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Create the tooltip container
|
||||
const dom = this.createTooltipContainer()
|
||||
|
||||
// Get active signature
|
||||
const activeSignatureIndex = result.activeSignature ?? 0
|
||||
const activeSignature =
|
||||
result.signatures[activeSignatureIndex] || result.signatures[0]
|
||||
|
||||
if (!activeSignature) {
|
||||
return null
|
||||
}
|
||||
|
||||
const activeParameterIndex =
|
||||
result.activeParameter || activeSignature.activeParameter
|
||||
|
||||
// Create and add signature display element
|
||||
const signatureElement = this.createSignatureElement(
|
||||
activeSignature,
|
||||
activeParameterIndex
|
||||
)
|
||||
dom.appendChild(signatureElement)
|
||||
|
||||
// Add documentation if available
|
||||
if (activeSignature.documentation) {
|
||||
dom.appendChild(
|
||||
this.createDocumentationElement(activeSignature.documentation)
|
||||
)
|
||||
}
|
||||
|
||||
if (activeParameterIndex) {
|
||||
// Add parameter documentation if available
|
||||
const activeParam = activeSignature.parameters?.[activeParameterIndex]
|
||||
|
||||
if (activeParam?.documentation) {
|
||||
dom.appendChild(this.createParameterDocElement(activeParam))
|
||||
}
|
||||
} else {
|
||||
// Append docs for all the parameters.
|
||||
activeSignature.parameters?.forEach((param) => {
|
||||
if (param.documentation) {
|
||||
dom.appendChild(this.createParameterDocElement(param))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Position tooltip at cursor
|
||||
let pos = posToOffset(view.state.doc, { line, character })
|
||||
if (pos === null || pos === undefined) return null
|
||||
|
||||
return {
|
||||
pos,
|
||||
end: pos,
|
||||
create: (_view) => ({ dom }),
|
||||
above: true,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Signature help error:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a signature help tooltip at the specified position
|
||||
*/
|
||||
public async showSignatureHelpTooltip(
|
||||
view: EditorView,
|
||||
pos: number,
|
||||
triggerCharacter?: string
|
||||
) {
|
||||
const tooltip = await this.requestSignatureHelp(
|
||||
view,
|
||||
offsetToPos(view.state.doc, pos),
|
||||
triggerCharacter
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
// Create and show the tooltip manually
|
||||
const { pos: tooltipPos, create } = tooltip
|
||||
const tooltipView = create(view)
|
||||
|
||||
const tooltipElement = document.createElement('div')
|
||||
tooltipElement.className =
|
||||
'documentation hover-tooltip cm-tooltip cm-signature-tooltip'
|
||||
tooltipElement.style.position = 'absolute'
|
||||
tooltipElement.style.zIndex = '99999999'
|
||||
|
||||
tooltipElement.appendChild(tooltipView.dom)
|
||||
|
||||
// Position the tooltip
|
||||
const coords = view.coordsAtPos(tooltipPos)
|
||||
if (coords) {
|
||||
tooltipElement.style.left = `${coords.left}px`
|
||||
tooltipElement.style.top = `${coords.bottom + 5}px`
|
||||
|
||||
// Add to DOM
|
||||
document.body.appendChild(tooltipElement)
|
||||
|
||||
// Remove after a delay or on editor changes
|
||||
setTimeout(() => {
|
||||
removeTooltip() // Use the function that also cleans up event listeners
|
||||
}, 10000) // Show for 10 seconds
|
||||
|
||||
// Also remove on any user input
|
||||
const removeTooltip = () => {
|
||||
tooltipElement.remove()
|
||||
view.dom.removeEventListener('keydown', removeTooltip)
|
||||
view.dom.removeEventListener('mousedown', removeTooltip)
|
||||
}
|
||||
|
||||
view.dom.addEventListener('keydown', removeTooltip)
|
||||
view.dom.addEventListener('mousedown', removeTooltip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the main tooltip container for signature help
|
||||
*/
|
||||
private createTooltipContainer(): HTMLElement {
|
||||
const dom = document.createElement('div')
|
||||
dom.classList.add('cm-signature-help')
|
||||
return dom
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the signature element with parameter highlighting
|
||||
*/
|
||||
private createSignatureElement(
|
||||
signature: LSP.SignatureInformation,
|
||||
activeParameterIndex?: number
|
||||
): HTMLElement {
|
||||
const signatureElement = document.createElement('div')
|
||||
signatureElement.classList.add('cm-signature')
|
||||
signatureElement.style.cssText =
|
||||
'font-family: monospace; margin-bottom: 4px;'
|
||||
|
||||
if (!signature.label || typeof signature.label !== 'string') {
|
||||
signatureElement.textContent = 'Signature information unavailable'
|
||||
return signatureElement
|
||||
}
|
||||
|
||||
const signatureText = signature.label
|
||||
const parameters = signature.parameters || []
|
||||
|
||||
// If there are no parameters or no active parameter, just show the signature text
|
||||
if (
|
||||
parameters.length === 0 ||
|
||||
!activeParameterIndex ||
|
||||
!parameters[activeParameterIndex]
|
||||
) {
|
||||
signatureElement.textContent = signatureText
|
||||
return signatureElement
|
||||
}
|
||||
|
||||
// Handle parameter highlighting based on the parameter label type
|
||||
const paramLabel = parameters[activeParameterIndex].label
|
||||
|
||||
if (typeof paramLabel === 'string') {
|
||||
// Simple string replacement
|
||||
if (this.allowHTMLContent) {
|
||||
signatureElement.innerHTML = signatureText.replace(
|
||||
paramLabel,
|
||||
`<strong class="cm-signature-active-param">${paramLabel}</strong>`
|
||||
)
|
||||
} else {
|
||||
signatureElement.textContent = signatureText.replace(
|
||||
paramLabel,
|
||||
`«${paramLabel}»`
|
||||
)
|
||||
}
|
||||
} else if (isArray(paramLabel) && paramLabel.length === 2) {
|
||||
// Handle array format [startIndex, endIndex]
|
||||
this.applyRangeHighlighting(
|
||||
signatureElement,
|
||||
signatureText,
|
||||
paramLabel[0],
|
||||
paramLabel[1]
|
||||
)
|
||||
} else {
|
||||
signatureElement.textContent = signatureText
|
||||
}
|
||||
|
||||
return signatureElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies parameter highlighting using a range approach
|
||||
*/
|
||||
private applyRangeHighlighting(
|
||||
element: HTMLElement,
|
||||
text: string,
|
||||
startIndex: number,
|
||||
endIndex: number
|
||||
): void {
|
||||
// Clear any existing content
|
||||
element.textContent = ''
|
||||
|
||||
// Split the text into three parts: before, parameter, after
|
||||
const beforeParam = text.substring(0, startIndex)
|
||||
const param = text.substring(startIndex, endIndex)
|
||||
const afterParam = text.substring(endIndex)
|
||||
|
||||
// Add the parts to the element
|
||||
element.appendChild(document.createTextNode(beforeParam))
|
||||
|
||||
const paramSpan = document.createElement('span')
|
||||
paramSpan.classList.add('cm-signature-active-param')
|
||||
paramSpan.style.cssText = 'font-weight: bold; text-decoration: underline;'
|
||||
paramSpan.textContent = param
|
||||
element.appendChild(paramSpan)
|
||||
|
||||
element.appendChild(document.createTextNode(afterParam))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the documentation element for signatures
|
||||
*/
|
||||
private createDocumentationElement(
|
||||
documentation: string | LSP.MarkupContent
|
||||
): HTMLElement {
|
||||
const docsElement = document.createElement('div')
|
||||
docsElement.classList.add('cm-signature-docs')
|
||||
docsElement.style.cssText = 'margin-top: 4px; color: #666;'
|
||||
|
||||
const formattedContent = formatContents(documentation)
|
||||
|
||||
if (this.allowHTMLContent) {
|
||||
docsElement.innerHTML = formattedContent
|
||||
} else {
|
||||
docsElement.textContent = formattedContent
|
||||
}
|
||||
|
||||
return docsElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the parameter documentation element
|
||||
*/
|
||||
private createParameterDocElement(
|
||||
parameter: LSP.ParameterInformation
|
||||
): HTMLElement {
|
||||
const paramDocsElement = document.createElement('div')
|
||||
paramDocsElement.classList.add('cm-parameter-docs')
|
||||
paramDocsElement.style.cssText =
|
||||
'margin-top: 4px; font-style: italic; border-top: 1px solid #eee; padding-top: 4px;'
|
||||
|
||||
const formattedContent =
|
||||
`<strong>${parameter.label}:</strong> ` +
|
||||
formatContents(parameter.documentation)
|
||||
|
||||
if (this.allowHTMLContent) {
|
||||
paramDocsElement.innerHTML = formattedContent
|
||||
} else {
|
||||
paramDocsElement.textContent = formattedContent
|
||||
}
|
||||
|
||||
return paramDocsElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback implementation of prepareRename.
|
||||
* We try to find the word at the cursor position and return the range of the word.
|
||||
*/
|
||||
private prepareRenameFallback(
|
||||
view: EditorView,
|
||||
{ line, character }: { line: number; character: number }
|
||||
): LSP.PrepareRenameResult | null {
|
||||
const doc = view.state.doc
|
||||
const lineText = doc.line(line + 1).text
|
||||
const wordRegex = /\w+/g
|
||||
let match: RegExpExecArray | null
|
||||
let start = character
|
||||
let end = character
|
||||
// Find all word matches in the line
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
|
||||
while ((match = wordRegex.exec(lineText)) !== null) {
|
||||
const matchStart = match.index
|
||||
const matchEnd = match.index + match[0].length
|
||||
|
||||
// Check if cursor position is within or at the boundaries of this word
|
||||
if (character >= matchStart && character <= matchEnd) {
|
||||
start = matchStart
|
||||
end = matchEnd
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (start === character && end === character) {
|
||||
return null // No word found at cursor position
|
||||
}
|
||||
|
||||
return {
|
||||
range: {
|
||||
start: {
|
||||
line,
|
||||
character: start,
|
||||
},
|
||||
end: {
|
||||
line,
|
||||
character: end,
|
||||
},
|
||||
},
|
||||
placeholder: lineText.slice(start, end),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply workspace edit from rename operation
|
||||
* @param view The editor view
|
||||
* @param edit The workspace edit to apply
|
||||
* @returns True if changes were applied successfully
|
||||
*/
|
||||
protected async applyRenameEdit(
|
||||
view: EditorView,
|
||||
edit: LSP.WorkspaceEdit | null
|
||||
): Promise<boolean> {
|
||||
if (!edit) {
|
||||
showErrorMessage(view, 'No edit returned from language server')
|
||||
return false
|
||||
}
|
||||
|
||||
const changesMap = edit.changes ?? {}
|
||||
const documentChanges = edit.documentChanges ?? []
|
||||
|
||||
if (Object.keys(changesMap).length === 0 && documentChanges.length === 0) {
|
||||
showErrorMessage(view, 'No changes to apply')
|
||||
return false
|
||||
}
|
||||
|
||||
// Handle documentChanges (preferred) if available
|
||||
if (documentChanges.length > 0) {
|
||||
for (const docChange of documentChanges) {
|
||||
if ('textDocument' in docChange) {
|
||||
// This is a TextDocumentEdit
|
||||
const uri = docChange.textDocument.uri
|
||||
|
||||
if (uri !== this.getDocUri()) {
|
||||
showErrorMessage(view, 'Multi-file rename not supported yet')
|
||||
continue
|
||||
}
|
||||
|
||||
// Sort edits in reverse order to avoid position shifts
|
||||
const sortedEdits = docChange.edits.sort((a, b) => {
|
||||
const posA = posToOffset(view.state.doc, a.range.start)
|
||||
const posB = posToOffset(view.state.doc, b.range.start)
|
||||
return (posB ?? 0) - (posA ?? 0)
|
||||
})
|
||||
|
||||
// Create a single transaction with all changes
|
||||
const changes = sortedEdits.map((edit) => ({
|
||||
from: posToOffset(view.state.doc, edit.range.start) ?? 0,
|
||||
to: posToOffset(view.state.doc, edit.range.end) ?? 0,
|
||||
insert: edit.newText,
|
||||
annotations: [lspRenameEvent],
|
||||
}))
|
||||
|
||||
view.dispatch(view.state.update({ changes }))
|
||||
return true
|
||||
}
|
||||
|
||||
// This is a CreateFile, RenameFile, or DeleteFile operation
|
||||
showErrorMessage(
|
||||
view,
|
||||
'File creation, deletion, or renaming operations not supported yet'
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Fall back to changes if documentChanges is not available
|
||||
else if (Object.keys(changesMap).length > 0) {
|
||||
// Apply all changes
|
||||
for (const [uri, changes] of Object.entries(changesMap)) {
|
||||
if (uri !== this.getDocUri()) {
|
||||
showErrorMessage(view, 'Multi-file rename not supported yet')
|
||||
continue
|
||||
}
|
||||
|
||||
// Sort changes in reverse order to avoid position shifts
|
||||
const sortedChanges = changes.sort((a, b) => {
|
||||
const posA = posToOffset(view.state.doc, a.range.start)
|
||||
const posB = posToOffset(view.state.doc, b.range.start)
|
||||
return (posB ?? 0) - (posA ?? 0)
|
||||
})
|
||||
|
||||
// Create a single transaction with all changes
|
||||
const changeSpecs = sortedChanges.map((change) => ({
|
||||
from: posToOffset(view.state.doc, change.range.start) ?? 0,
|
||||
to: posToOffset(view.state.doc, change.range.end) ?? 0,
|
||||
insert: change.newText,
|
||||
}))
|
||||
|
||||
view.dispatch(view.state.update({ changes: changeSpecs }))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
parseSemanticTokens(view: EditorView, data: number[]) {
|
||||
// decode the lsp semantic token types
|
||||
const tokens = []
|
||||
@ -509,7 +1204,7 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
params
|
||||
)
|
||||
// this is sometimes slower than our actual typing.
|
||||
this.processDiagnostics(params)
|
||||
await this.processDiagnostics(params)
|
||||
break
|
||||
case 'window/logMessage':
|
||||
console.log(
|
||||
@ -534,25 +1229,74 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
this.processLspNotification?.(this, notification)
|
||||
}
|
||||
|
||||
processDiagnostics(params: PublishDiagnosticsParams) {
|
||||
async processDiagnostics(params: PublishDiagnosticsParams) {
|
||||
if (params.uri !== this.getDocUri()) return
|
||||
|
||||
// Commented to avoid the lint. See TODO below.
|
||||
// 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,
|
||||
}))
|
||||
const rawDiagnostics = params.diagnostics.map(
|
||||
async ({ range, message, severity, code }) => {
|
||||
const actions = await this.requestCodeActions(range, [code as string])
|
||||
|
||||
const codemirrorActions = actions?.map(
|
||||
(action): Action => ({
|
||||
name:
|
||||
'command' in action && typeof action.command === 'object'
|
||||
? action.command?.title || action.title
|
||||
: action.title,
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
apply: async () => {
|
||||
if ('edit' in action && action.edit?.changes) {
|
||||
const changes = action.edit.changes[this.getDocUri()]
|
||||
|
||||
if (!changes) {
|
||||
return
|
||||
}
|
||||
|
||||
// Apply workspace edit
|
||||
for (const change of changes) {
|
||||
this.view.dispatch(
|
||||
this.view.state.update({
|
||||
changes: {
|
||||
from: posToOffsetOrZero(
|
||||
this.view.state.doc,
|
||||
change.range.start
|
||||
),
|
||||
to: posToOffset(this.view.state.doc, change.range.end),
|
||||
insert: change.newText,
|
||||
},
|
||||
annotations: [lspCodeActionEvent],
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if ('command' in action && action.command) {
|
||||
// TODO: Implement command execution
|
||||
// Execute command if present
|
||||
console.warn(
|
||||
'[codemirror-lsp-client/processDiagnostics] executing command:',
|
||||
action.command
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const diagnostic: Diagnostic = {
|
||||
from: posToOffsetOrZero(this.view.state.doc, range.start),
|
||||
to: posToOffsetOrZero(this.view.state.doc, range.end),
|
||||
severity: severityMap[severity ?? DiagnosticSeverity.Error],
|
||||
source: this.getLanguageId(),
|
||||
actions: codemirrorActions,
|
||||
message,
|
||||
}
|
||||
|
||||
return diagnostic
|
||||
}
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const diagnostics = (await Promise.all(rawDiagnostics))
|
||||
.filter(
|
||||
({ from, to }) =>
|
||||
from !== null && to !== null && from !== undefined && to !== undefined
|
||||
@ -581,12 +1325,15 @@ export class LanguageServerPluginSpec
|
||||
{
|
||||
provide(plugin: ViewPlugin<LanguageServerPlugin>): Extension {
|
||||
return [
|
||||
linter(null),
|
||||
lspAutocompleteExt(plugin),
|
||||
lspFormatExt(plugin),
|
||||
lspGoToDefinitionExt(plugin),
|
||||
lspHoverExt(plugin),
|
||||
lspIndentExt(),
|
||||
lspRenameExt(plugin),
|
||||
lspSemanticTokensExt(),
|
||||
linter(null),
|
||||
lspSignatureHelpExt(plugin),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
32
packages/codemirror-lsp-client/src/plugin/rename.ts
Normal file
32
packages/codemirror-lsp-client/src/plugin/rename.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import { Prec } from '@codemirror/state'
|
||||
import type { ViewPlugin } from '@codemirror/view'
|
||||
import { keymap } from '@codemirror/view'
|
||||
|
||||
import type { LanguageServerPlugin } from './lsp'
|
||||
import { offsetToPos } from './util'
|
||||
|
||||
export default function lspRenameExt(
|
||||
plugin: ViewPlugin<LanguageServerPlugin>
|
||||
): Extension {
|
||||
return [
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: 'F2',
|
||||
run: (view) => {
|
||||
if (!plugin) return false
|
||||
|
||||
const value = view.plugin(plugin)
|
||||
if (!value) return false
|
||||
|
||||
const pos = view.state.selection.main.head
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
value.requestRename(view, offsetToPos(view.state.doc, pos))
|
||||
return true
|
||||
},
|
||||
},
|
||||
])
|
||||
),
|
||||
]
|
||||
}
|
||||
90
packages/codemirror-lsp-client/src/plugin/signature-help.ts
Normal file
90
packages/codemirror-lsp-client/src/plugin/signature-help.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import { Prec } from '@codemirror/state'
|
||||
import type { ViewPlugin } from '@codemirror/view'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { keymap } from '@codemirror/view'
|
||||
|
||||
import type { LanguageServerPlugin } from './lsp'
|
||||
|
||||
export default function lspSignatureHelpExt(
|
||||
plugin: ViewPlugin<LanguageServerPlugin>
|
||||
): Extension {
|
||||
return [
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Mod-Shift-Space',
|
||||
run: (view) => {
|
||||
if (!plugin) {
|
||||
return false
|
||||
}
|
||||
|
||||
const value = view.plugin(plugin)
|
||||
if (!value) return false
|
||||
|
||||
const pos = view.state.selection.main.head
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
value.showSignatureHelpTooltip(view, pos)
|
||||
return true
|
||||
},
|
||||
},
|
||||
])
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
EditorView.updateListener.of(async (update) => {
|
||||
if (!(plugin && update.docChanged)) return
|
||||
|
||||
// Make sure this is a valid user typing event.
|
||||
let isRelevant = false
|
||||
for (const tr of update.transactions) {
|
||||
if (tr.isUserEvent('input')) {
|
||||
isRelevant = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRelevant) {
|
||||
// We only want signature help on user events.
|
||||
return
|
||||
}
|
||||
|
||||
const value = update.view.plugin(plugin)
|
||||
if (!value) return false
|
||||
|
||||
// Early exit if signature help capability is not supported
|
||||
if (!value.client.getServerCapabilities().signatureHelpProvider) return
|
||||
|
||||
const triggerChars = value.client.getServerCapabilities()
|
||||
.signatureHelpProvider?.triggerCharacters || ['(', ',']
|
||||
let triggerCharacter: string | undefined
|
||||
|
||||
// Check if changes include trigger characters
|
||||
const changes = update.changes
|
||||
let shouldTrigger = false
|
||||
let triggerPos = -1
|
||||
|
||||
changes.iterChanges((_fromA, _toA, _fromB, toB, inserted) => {
|
||||
if (shouldTrigger) return // Skip if already found a trigger
|
||||
|
||||
const text = inserted.toString()
|
||||
if (!text) return
|
||||
|
||||
for (const char of triggerChars) {
|
||||
if (text.includes(char)) {
|
||||
shouldTrigger = true
|
||||
triggerPos = toB
|
||||
triggerCharacter = char
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (shouldTrigger && triggerPos >= 0) {
|
||||
await value.showSignatureHelpTooltip(
|
||||
update.view,
|
||||
triggerPos,
|
||||
triggerCharacter
|
||||
)
|
||||
}
|
||||
}),
|
||||
]
|
||||
}
|
||||
@ -2,6 +2,7 @@ import type { Text } from '@codemirror/state'
|
||||
import type { MarkedOptions } from '@ts-stack/markdown'
|
||||
import { Marked } from '@ts-stack/markdown'
|
||||
import type * as LSP from 'vscode-languageserver-protocol'
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
|
||||
import { isArray } from '../lib/utils'
|
||||
|
||||
@ -36,6 +37,13 @@ export function posToOffset(
|
||||
return offset
|
||||
}
|
||||
|
||||
export function posToOffsetOrZero(
|
||||
doc: Text,
|
||||
pos: { line: number; character: number }
|
||||
): number {
|
||||
return posToOffset(doc, pos) || 0
|
||||
}
|
||||
|
||||
export function offsetToPos(doc: Text, offset: number) {
|
||||
const line = doc.lineAt(offset)
|
||||
return {
|
||||
@ -48,14 +56,84 @@ const markedOptions: MarkedOptions = {
|
||||
gfm: true,
|
||||
}
|
||||
|
||||
export function formatMarkdownContents(
|
||||
contents: LSP.MarkupContent | LSP.MarkedString | LSP.MarkedString[]
|
||||
): string {
|
||||
if (isArray(contents)) {
|
||||
return contents.map((c) => formatMarkdownContents(c) + '\n\n').join('')
|
||||
} else if (typeof contents === 'string') {
|
||||
return Marked.parse(contents, markedOptions)
|
||||
} else {
|
||||
return Marked.parse(contents.value, markedOptions)
|
||||
}
|
||||
export function isLSPTextEdit(
|
||||
textEdit?: LSP.TextEdit | LSP.InsertReplaceEdit
|
||||
): textEdit is LSP.TextEdit {
|
||||
return (textEdit as LSP.TextEdit)?.range !== undefined
|
||||
}
|
||||
|
||||
export function isLSPMarkupContent(
|
||||
contents: LSP.MarkupContent | LSP.MarkedString | LSP.MarkedString[]
|
||||
): contents is LSP.MarkupContent {
|
||||
return (contents as LSP.MarkupContent).kind !== undefined
|
||||
}
|
||||
|
||||
export function formatContents(
|
||||
contents:
|
||||
| LSP.MarkupContent
|
||||
| LSP.MarkedString
|
||||
| LSP.MarkedString[]
|
||||
| undefined
|
||||
): string {
|
||||
if (!contents) {
|
||||
return ''
|
||||
}
|
||||
if (isLSPMarkupContent(contents)) {
|
||||
let value = contents.value
|
||||
if (contents.kind === 'markdown') {
|
||||
value = Marked.parse(value, markedOptions)
|
||||
}
|
||||
return value
|
||||
}
|
||||
if (isArray(contents)) {
|
||||
return contents
|
||||
.map((c) => formatContents(c))
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
}
|
||||
if (typeof contents === 'string') {
|
||||
return contents
|
||||
}
|
||||
if (
|
||||
typeof contents === 'object' &&
|
||||
'language' in contents &&
|
||||
'value' in contents
|
||||
) {
|
||||
return contents.value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function showErrorMessage(view: EditorView, message: string) {
|
||||
const tooltip = document.createElement('div')
|
||||
tooltip.className = 'cm-error-message'
|
||||
tooltip.style.cssText = `
|
||||
position: absolute;
|
||||
padding: 8px;
|
||||
background: #fee;
|
||||
border: 1px solid #fcc;
|
||||
border-radius: 4px;
|
||||
color: #c00;
|
||||
font-size: 14px;
|
||||
z-index: 100;
|
||||
max-width: 300px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.15);
|
||||
`
|
||||
tooltip.textContent = message
|
||||
|
||||
// Position near the cursor
|
||||
const cursor = view.coordsAtPos(view.state.selection.main.head)
|
||||
if (cursor) {
|
||||
tooltip.style.left = `${cursor.left}px`
|
||||
tooltip.style.top = `${cursor.bottom + 5}px`
|
||||
}
|
||||
|
||||
document.body.appendChild(tooltip)
|
||||
|
||||
// Remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
tooltip.style.opacity = '0'
|
||||
tooltip.style.transition = 'opacity 0.2s'
|
||||
setTimeout(() => tooltip.remove(), 200)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user