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:
Jess Frazelle
2025-04-29 17:57:02 -07:00
committed by GitHub
parent 844f229b5a
commit 29b8a442c2
35 changed files with 6746 additions and 80 deletions

View File

@ -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) {

View File

@ -18,7 +18,9 @@ export { Codec } from './client/codec/utils'
export {
lspDiagnosticsEvent,
lspFormatCodeEvent,
lspRenameEvent,
lspSemanticTokensEvent,
lspCodeActionEvent,
} from './plugin/annotation'
export {
LanguageServerPlugin,

View File

@ -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)

View File

@ -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 }
}

View File

@ -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
},
},
])
),
]
}

View File

@ -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),
]
}
}

View 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
},
},
])
),
]
}

View 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
)
}
}),
]
}

View File

@ -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)
}