2024-04-15 17:18:32 -07:00
|
|
|
import {
|
|
|
|
completeFromList,
|
|
|
|
hasNextSnippetField,
|
|
|
|
snippetCompletion,
|
|
|
|
} from '@codemirror/autocomplete'
|
2023-09-05 16:02:27 -07:00
|
|
|
import { setDiagnostics } from '@codemirror/lint'
|
|
|
|
import { Facet } from '@codemirror/state'
|
2024-02-19 12:33:16 -08:00
|
|
|
import { EditorView, Tooltip } from '@codemirror/view'
|
2023-09-05 16:02:27 -07:00
|
|
|
import {
|
|
|
|
DiagnosticSeverity,
|
|
|
|
CompletionItemKind,
|
|
|
|
CompletionTriggerKind,
|
|
|
|
} from 'vscode-languageserver-protocol'
|
|
|
|
|
2024-04-15 17:18:32 -07:00
|
|
|
import { deferExecution } from 'lib/utils'
|
2023-09-05 16:02:27 -07:00
|
|
|
import type {
|
|
|
|
Completion,
|
|
|
|
CompletionContext,
|
|
|
|
CompletionResult,
|
|
|
|
} from '@codemirror/autocomplete'
|
|
|
|
import type { PublishDiagnosticsParams } from 'vscode-languageserver-protocol'
|
|
|
|
import type { ViewUpdate, PluginValue } from '@codemirror/view'
|
|
|
|
import type * as LSP from 'vscode-languageserver-protocol'
|
2024-02-19 12:33:16 -08:00
|
|
|
import { LanguageServerClient } from 'editor/plugins/lsp'
|
2023-09-05 16:02:27 -07:00
|
|
|
import { Marked } from '@ts-stack/markdown'
|
2024-02-19 12:33:16 -08:00
|
|
|
import { posToOffset } from 'editor/plugins/lsp/util'
|
2024-04-15 17:18:32 -07:00
|
|
|
import { Program, ProgramMemory } from 'lang/wasm'
|
|
|
|
import { kclManager } from 'lib/singletons'
|
|
|
|
import type { UnitLength } from 'wasm-lib/kcl/bindings/UnitLength'
|
|
|
|
import { UpdateUnitsResponse } from 'wasm-lib/kcl/bindings/UpdateUnitsResponse'
|
|
|
|
import { UpdateCanExecuteResponse } from 'wasm-lib/kcl/bindings/UpdateCanExecuteResponse'
|
2024-02-19 12:33:16 -08:00
|
|
|
|
|
|
|
const useLast = (values: readonly any[]) => values.reduce((_, v) => v, '')
|
|
|
|
export const documentUri = Facet.define<string, string>({ combine: useLast })
|
|
|
|
export const languageId = Facet.define<string, string>({ combine: useLast })
|
|
|
|
export const workspaceFolders = Facet.define<
|
|
|
|
LSP.WorkspaceFolder[],
|
|
|
|
LSP.WorkspaceFolder[]
|
|
|
|
>({ combine: useLast })
|
2023-09-05 16:02:27 -07:00
|
|
|
|
|
|
|
const CompletionItemKindMap = Object.fromEntries(
|
|
|
|
Object.entries(CompletionItemKind).map(([key, value]) => [value, key])
|
|
|
|
) as Record<CompletionItemKind, string>
|
|
|
|
|
2024-04-15 17:18:32 -07:00
|
|
|
const changesDelay = 600
|
|
|
|
|
2023-09-05 16:02:27 -07:00
|
|
|
export class LanguageServerPlugin implements PluginValue {
|
|
|
|
public client: LanguageServerClient
|
2024-03-11 17:50:31 -07:00
|
|
|
public documentUri: string
|
|
|
|
public languageId: string
|
|
|
|
public workspaceFolders: LSP.WorkspaceFolder[]
|
2023-09-05 16:02:27 -07:00
|
|
|
private documentVersion: number
|
2024-04-15 17:18:32 -07:00
|
|
|
private foldingRanges: LSP.FoldingRange[] | null = null
|
|
|
|
private _defferer = deferExecution((code: string) => {
|
|
|
|
try {
|
|
|
|
this.client.textDocumentDidChange({
|
|
|
|
textDocument: {
|
|
|
|
uri: this.documentUri,
|
|
|
|
version: this.documentVersion++,
|
|
|
|
},
|
|
|
|
contentChanges: [{ text: code }],
|
|
|
|
})
|
|
|
|
} catch (e) {
|
|
|
|
console.error(e)
|
|
|
|
}
|
|
|
|
}, changesDelay)
|
2023-09-05 16:02:27 -07:00
|
|
|
|
2024-02-19 12:33:16 -08:00
|
|
|
constructor(
|
|
|
|
client: LanguageServerClient,
|
|
|
|
private view: EditorView,
|
|
|
|
private allowHTMLContent: boolean
|
|
|
|
) {
|
|
|
|
this.client = client
|
2023-09-05 16:02:27 -07:00
|
|
|
this.documentUri = this.view.state.facet(documentUri)
|
|
|
|
this.languageId = this.view.state.facet(languageId)
|
2024-02-19 12:33:16 -08:00
|
|
|
this.workspaceFolders = this.view.state.facet(workspaceFolders)
|
2023-09-05 16:02:27 -07:00
|
|
|
this.documentVersion = 0
|
|
|
|
|
|
|
|
this.client.attachPlugin(this)
|
|
|
|
|
2024-02-11 12:59:00 +11:00
|
|
|
this.initialize({
|
2023-09-05 16:02:27 -07:00
|
|
|
documentText: this.view.state.doc.toString(),
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2024-04-15 17:18:32 -07:00
|
|
|
update({ docChanged, state }: ViewUpdate) {
|
2023-09-05 16:02:27 -07:00
|
|
|
if (!docChanged) return
|
2023-09-21 16:13:22 -07:00
|
|
|
|
2024-04-15 17:18:32 -07:00
|
|
|
// If we are just fucking around in a snippet, return early and don't
|
|
|
|
// trigger stuff below that might cause the component to re-render.
|
|
|
|
// Otherwise we will not be able to tab thru the snippet portions.
|
|
|
|
// We explicitly dont check HasPrevSnippetField because we always add
|
|
|
|
// a ${} to the end of the function so that's fine.
|
|
|
|
// We only care about this for the 'kcl' plugin.
|
|
|
|
if (this.client.name === 'kcl' && hasNextSnippetField(state)) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-02-11 12:59:00 +11:00
|
|
|
this.sendChange({
|
2023-09-21 16:13:22 -07:00
|
|
|
documentText: this.view.state.doc.toString(),
|
|
|
|
})
|
2023-09-05 16:02:27 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
destroy() {
|
|
|
|
this.client.detachPlugin(this)
|
|
|
|
}
|
|
|
|
|
|
|
|
async initialize({ documentText }: { documentText: string }) {
|
|
|
|
if (this.client.initializePromise) {
|
|
|
|
await this.client.initializePromise
|
|
|
|
}
|
|
|
|
this.client.textDocumentDidOpen({
|
|
|
|
textDocument: {
|
|
|
|
uri: this.documentUri,
|
|
|
|
languageId: this.languageId,
|
|
|
|
text: documentText,
|
|
|
|
version: this.documentVersion,
|
|
|
|
},
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
async sendChange({ documentText }: { documentText: string }) {
|
|
|
|
if (!this.client.ready) return
|
2023-09-21 16:13:22 -07:00
|
|
|
|
|
|
|
if (documentText.length > 5000) {
|
|
|
|
// Clear out the text it thinks we have, large documents will throw a stack error.
|
|
|
|
// This is obviously not a good fix but it works for now til we figure
|
|
|
|
// out the stack limits in wasm and also rewrite the parser.
|
|
|
|
// Since this is only for hover and completions it will be fine,
|
|
|
|
// completions will still work for stdlib but hover will not.
|
|
|
|
// That seems like a fine trade-off for a working editor for the time
|
|
|
|
// being.
|
|
|
|
documentText = ''
|
|
|
|
}
|
|
|
|
|
2024-04-15 17:18:32 -07:00
|
|
|
this._defferer(documentText)
|
2023-09-05 16:02:27 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
requestDiagnostics(view: EditorView) {
|
2024-02-11 12:59:00 +11:00
|
|
|
this.sendChange({ documentText: view.state.doc.toString() })
|
2023-09-05 16:02:27 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
async requestHoverTooltip(
|
|
|
|
view: EditorView,
|
|
|
|
{ line, character }: { line: number; character: number }
|
|
|
|
): Promise<Tooltip | null> {
|
|
|
|
if (
|
|
|
|
!this.client.ready ||
|
|
|
|
!this.client.getServerCapabilities().hoverProvider
|
|
|
|
)
|
|
|
|
return null
|
|
|
|
|
2024-02-11 12:59:00 +11:00
|
|
|
this.sendChange({ documentText: view.state.doc.toString() })
|
2023-09-05 16:02:27 -07:00
|
|
|
const result = await this.client.textDocumentHover({
|
|
|
|
textDocument: { uri: this.documentUri },
|
|
|
|
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')
|
|
|
|
if (this.allowHTMLContent) dom.innerHTML = formatContents(contents)
|
|
|
|
else dom.textContent = formatContents(contents)
|
|
|
|
return { pos, end, create: (view) => ({ dom }), above: true }
|
|
|
|
}
|
|
|
|
|
2024-04-15 17:18:32 -07:00
|
|
|
async getFoldingRanges(): Promise<LSP.FoldingRange[] | null> {
|
|
|
|
if (
|
|
|
|
!this.client.ready ||
|
|
|
|
!this.client.getServerCapabilities().foldingRangeProvider
|
|
|
|
)
|
|
|
|
return null
|
|
|
|
const result = await this.client.textDocumentFoldingRange({
|
|
|
|
textDocument: { uri: this.documentUri },
|
|
|
|
})
|
|
|
|
|
|
|
|
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 updateUnits(units: UnitLength): Promise<UpdateUnitsResponse | null> {
|
|
|
|
if (this.client.name !== 'kcl') return null
|
|
|
|
if (!this.client.ready) return null
|
|
|
|
|
|
|
|
return await this.client.updateUnits({
|
|
|
|
textDocument: {
|
|
|
|
uri: this.documentUri,
|
|
|
|
},
|
|
|
|
text: this.view.state.doc.toString(),
|
|
|
|
units,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
async updateCanExecute(
|
|
|
|
canExecute: boolean
|
|
|
|
): Promise<UpdateCanExecuteResponse | null> {
|
|
|
|
if (this.client.name !== 'kcl') return null
|
|
|
|
if (!this.client.ready) return null
|
|
|
|
|
|
|
|
let response = await this.client.updateCanExecute({
|
|
|
|
canExecute,
|
|
|
|
})
|
|
|
|
|
|
|
|
if (!canExecute && response.isExecuting) {
|
|
|
|
// We want to wait until the server is not busy before we reply to the
|
|
|
|
// caller.
|
|
|
|
while (response.isExecuting) {
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
|
|
response = await this.client.updateCanExecute({
|
|
|
|
canExecute,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
console.log('[lsp] kcl: updated canExecute', canExecute, response)
|
|
|
|
return response
|
|
|
|
}
|
|
|
|
|
|
|
|
async requestFormatting() {
|
|
|
|
if (
|
|
|
|
!this.client.ready ||
|
|
|
|
!this.client.getServerCapabilities().documentFormattingProvider
|
|
|
|
)
|
|
|
|
return null
|
|
|
|
|
|
|
|
this.sendChange({
|
|
|
|
documentText: this.view.state.doc.toString(),
|
|
|
|
})
|
|
|
|
|
|
|
|
const result = await this.client.textDocumentFormatting({
|
|
|
|
textDocument: { uri: this.documentUri },
|
|
|
|
options: {
|
|
|
|
tabSize: 2,
|
|
|
|
insertSpaces: true,
|
|
|
|
insertFinalNewline: true,
|
|
|
|
},
|
|
|
|
})
|
|
|
|
|
|
|
|
if (!result) return null
|
|
|
|
|
|
|
|
for (let i = 0; i < result.length; i++) {
|
|
|
|
const { range, newText } = result[i]
|
|
|
|
this.view.dispatch({
|
|
|
|
changes: [
|
|
|
|
{
|
|
|
|
from: posToOffset(this.view.state.doc, range.start)!,
|
|
|
|
to: posToOffset(this.view.state.doc, range.end)!,
|
|
|
|
insert: newText,
|
|
|
|
},
|
|
|
|
],
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-09-05 16:02:27 -07:00
|
|
|
async requestCompletion(
|
|
|
|
context: CompletionContext,
|
|
|
|
{ line, character }: { line: number; character: number },
|
|
|
|
{
|
|
|
|
triggerKind,
|
|
|
|
triggerCharacter,
|
|
|
|
}: {
|
|
|
|
triggerKind: CompletionTriggerKind
|
|
|
|
triggerCharacter: string | undefined
|
|
|
|
}
|
|
|
|
): Promise<CompletionResult | null> {
|
|
|
|
if (
|
|
|
|
!this.client.ready ||
|
|
|
|
!this.client.getServerCapabilities().completionProvider
|
|
|
|
)
|
|
|
|
return null
|
|
|
|
|
2024-02-11 12:59:00 +11:00
|
|
|
this.sendChange({
|
2023-09-05 16:02:27 -07:00
|
|
|
documentText: context.state.doc.toString(),
|
|
|
|
})
|
|
|
|
|
|
|
|
const result = await this.client.textDocumentCompletion({
|
|
|
|
textDocument: { uri: this.documentUri },
|
|
|
|
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) {
|
2023-09-06 21:27:30 -04:00
|
|
|
completion.info = () => {
|
|
|
|
const htmlString = formatContents(documentation)
|
|
|
|
const htmlNode = document.createElement('div')
|
|
|
|
htmlNode.style.display = 'contents'
|
|
|
|
htmlNode.innerHTML = htmlString
|
|
|
|
return { dom: htmlNode }
|
|
|
|
}
|
2023-09-05 16:02:27 -07:00
|
|
|
}
|
|
|
|
|
2024-04-12 13:28:58 -07:00
|
|
|
if (insertText && insertTextFormat === 2) {
|
|
|
|
return snippetCompletion(insertText, completion)
|
|
|
|
}
|
|
|
|
|
2023-09-05 16:02:27 -07:00
|
|
|
return completion
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
return completeFromList(options)(context)
|
|
|
|
}
|
|
|
|
|
2024-02-19 12:33:16 -08:00
|
|
|
processNotification(notification: LSP.NotificationMessage) {
|
2023-09-05 16:02:27 -07:00
|
|
|
try {
|
|
|
|
switch (notification.method) {
|
|
|
|
case 'textDocument/publishDiagnostics':
|
2024-04-15 17:18:32 -07:00
|
|
|
const params = notification.params as PublishDiagnosticsParams
|
|
|
|
this.processDiagnostics(params)
|
|
|
|
// Update the kcl errors pane.
|
|
|
|
/*kclManager.kclErrors = lspDiagnosticsToKclErrors(
|
|
|
|
this.view.state.doc,
|
|
|
|
params.diagnostics
|
|
|
|
)*/
|
2024-02-19 12:33:16 -08:00
|
|
|
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
|
2024-04-15 17:18:32 -07:00
|
|
|
case 'kcl/astUpdated':
|
|
|
|
// The server has updated the AST, we should update elsewhere.
|
|
|
|
let updatedAst = notification.params as Program
|
|
|
|
console.log('[lsp]: Updated AST', updatedAst)
|
2024-04-16 21:36:19 -07:00
|
|
|
// Since we aren't using the lsp server for executing the program
|
|
|
|
// we don't update the ast here.
|
|
|
|
//kclManager.ast = updatedAst
|
2024-04-15 17:18:32 -07:00
|
|
|
|
|
|
|
// Update the folding ranges, since the AST has changed.
|
|
|
|
// This is a hack since codemirror does not support async foldService.
|
|
|
|
// When they do we can delete this.
|
|
|
|
this.updateFoldingRanges()
|
|
|
|
break
|
|
|
|
case 'kcl/memoryUpdated':
|
|
|
|
// The server has updated the memory, we should update elsewhere.
|
|
|
|
let updatedMemory = notification.params as ProgramMemory
|
|
|
|
console.log('[lsp]: Updated Memory', updatedMemory)
|
|
|
|
kclManager.programMemory = updatedMemory
|
|
|
|
break
|
2023-09-05 16:02:27 -07:00
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
console.error(error)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
processDiagnostics(params: PublishDiagnosticsParams) {
|
|
|
|
if (params.uri !== this.documentUri) 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.view.dispatch(setDiagnostics(this.view.state, diagnostics))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function formatContents(
|
|
|
|
contents: LSP.MarkupContent | LSP.MarkedString | LSP.MarkedString[]
|
|
|
|
): string {
|
|
|
|
if (Array.isArray(contents)) {
|
|
|
|
return contents.map((c) => formatContents(c) + '\n\n').join('')
|
|
|
|
} else if (typeof contents === 'string') {
|
|
|
|
return Marked.parse(contents)
|
|
|
|
} else {
|
|
|
|
return Marked.parse(contents.value)
|
|
|
|
}
|
|
|
|
}
|