codemirror lsp highlighter (#2806)
* tokenizer 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> fixes Signed-off-by: Jess Frazelle <github@jessfraz.com> updates Signed-off-by: Jess Frazelle <github@jessfraz.com> A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) udates Signed-off-by: Jess Frazelle <github@jessfraz.com> fixes Signed-off-by: Jess Frazelle <github@jessfraz.com> updates Signed-off-by: Jess Frazelle <github@jessfraz.com> A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) updates Signed-off-by: Jess Frazelle <github@jessfraz.com> A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) updates Signed-off-by: Jess Frazelle <github@jessfraz.com> fixes Signed-off-by: Jess Frazelle <github@jessfraz.com> more cleaniup Signed-off-by: Jess Frazelle <github@jessfraz.com> updates Signed-off-by: Jess Frazelle <github@jessfraz.com> dont react to non relevant events Signed-off-by: Jess Frazelle <github@jessfraz.com> A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) faster Signed-off-by: Jess Frazelle <github@jessfraz.com> cleanup code Signed-off-by: Jess Frazelle <github@jessfraz.com> defer Signed-off-by: Jess Frazelle <github@jessfraz.com> more events Signed-off-by: Jess Frazelle <github@jessfraz.com> fixes 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> updates Signed-off-by: Jess Frazelle <github@jessfraz.com> updates Signed-off-by: Jess Frazelle <github@jessfraz.com> cleanup Signed-off-by: Jess Frazelle <github@jessfraz.com> user events Signed-off-by: Jess Frazelle <github@jessfraz.com> updates Signed-off-by: Jess Frazelle <github@jessfraz.com> udpates Signed-off-by: Jess Frazelle <github@jessfraz.com> updates ; Signed-off-by: Jess Frazelle <github@jessfraz.com> upfates Signed-off-by: Jess Frazelle <github@jessfraz.com> updates Signed-off-by: Jess Frazelle <github@jessfraz.com> make highlighting code blocks easier Signed-off-by: Jess Frazelle <github@jessfraz.com> improvements 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> cleanup Signed-off-by: Jess Frazelle <github@jessfraz.com> updates Signed-off-by: Jess Frazelle <github@jessfraz.com> better builds Signed-off-by: Jess Frazelle <github@jessfraz.com> remove weird hacks Signed-off-by: Jess Frazelle <github@jessfraz.com> updates Signed-off-by: Jess Frazelle <github@jessfraz.com> better checks Signed-off-by: Jess Frazelle <github@jessfraz.com> A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) make release builds in prod (#2839) Update package.json udpates Signed-off-by: Jess Frazelle <github@jessfraz.com> updates Signed-off-by: Jess Frazelle <github@jessfraz.com> fix some 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> A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) updates Signed-off-by: Jess Frazelle <github@jessfraz.com> better timing Signed-off-by: Jess Frazelle <github@jessfraz.com> tweak delay Signed-off-by: Jess Frazelle <github@jessfraz.com> A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) upfates Signed-off-by: Jess Frazelle <github@jessfraz.com> fixes Signed-off-by: Jess Frazelle <github@jessfraz.com> fixes 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> updates Signed-off-by: Jess Frazelle <github@jessfraz.com> ifxup Signed-off-by: Jess Frazelle <github@jessfraz.com> udpates Signed-off-by: Jess Frazelle <github@jessfraz.com> udpates Signed-off-by: Jess Frazelle <github@jessfraz.com> A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) udpates Signed-off-by: Jess Frazelle <github@jessfraz.com> wait for the lsp for all screenshots so consistent Signed-off-by: Jess Frazelle <github@jessfraz.com> better playwright Signed-off-by: Jess Frazelle <github@jessfraz.com> A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) fixes Signed-off-by: Jess Frazelle <github@jessfraz.com> Call core dump from the bug reporting button(s) (#2783) * Add coredump to refresh button - this one indicates that there should be something like a core dump that is triggered. * Added lower right control bug report button - included custom toasts for bug reporting, supports fallback bug reporting when app cannot generate a core dump better keymaps Signed-off-by: Jess Frazelle <github@jessfraz.com> emptu in comment Signed-off-by: Jess Frazelle <github@jessfraz.com> fix logs Signed-off-by: Jess Frazelle <github@jessfraz.com> fxes Signed-off-by: Jess Frazelle <github@jessfraz.com> add a test for tab to autocomplete Signed-off-by: Jess Frazelle <github@jessfraz.com> updates Signed-off-by: Jess Frazelle <github@jessfraz.com> better Signed-off-by: Jess Frazelle <github@jessfraz.com> printl Signed-off-by: Jess Frazelle <github@jessfraz.com> * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * updates Signed-off-by: Jess Frazelle <github@jessfraz.com> * updates Signed-off-by: Jess Frazelle <github@jessfraz.com> * upfates Signed-off-by: Jess Frazelle <github@jessfraz.com> * cleanup 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> * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * empty * fix Signed-off-by: Jess Frazelle <github@jessfraz.com> --------- Signed-off-by: Jess Frazelle <github@jessfraz.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
@ -568,6 +568,7 @@ export class SceneEntities {
|
||||
|
||||
if (shouldTearDown) await this.tearDownSketch({ removeAxis: false })
|
||||
sceneInfra.resetMouseListeners()
|
||||
|
||||
const { truncatedAst, programMemoryOverride, sketchGroup } =
|
||||
await this.setupSketch({
|
||||
sketchPathToNode,
|
||||
|
@ -10,7 +10,7 @@ import React, {
|
||||
import { FromServer, IntoServer } from 'editor/plugins/lsp/codec'
|
||||
import Client from '../editor/plugins/lsp/client'
|
||||
import { TEST, VITE_KC_API_BASE_URL } from 'env'
|
||||
import kclLanguage from 'editor/plugins/lsp/kcl/language'
|
||||
import KclLanguageSupport from 'editor/plugins/lsp/kcl/language'
|
||||
import { copilotPlugin } from 'editor/plugins/lsp/copilot'
|
||||
import { useStore } from 'useStore'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
@ -31,6 +31,8 @@ import { PROJECT_ENTRYPOINT } from 'lib/constants'
|
||||
import { useNetworkContext } from 'hooks/useNetworkContext'
|
||||
import { NetworkHealthState } from 'hooks/useNetworkStatus'
|
||||
import { err } from 'lib/trap'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { codeManager } from 'lib/singletons'
|
||||
|
||||
function getWorkspaceFolders(): LSP.WorkspaceFolder[] {
|
||||
return []
|
||||
@ -128,17 +130,31 @@ export const LspProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const fromServer: FromServer | Error = FromServer.create()
|
||||
if (err(fromServer)) return { lspClient: null }
|
||||
|
||||
const client = new Client(fromServer, intoServer)
|
||||
|
||||
setIsLspReady(true)
|
||||
const client = new Client(fromServer, intoServer, () => {
|
||||
setIsLspReady(true)
|
||||
})
|
||||
|
||||
const lspClient = new LanguageServerClient({ client, name: LspWorker.Kcl })
|
||||
|
||||
return { lspClient }
|
||||
}, [
|
||||
// We need a token for authenticating the server.
|
||||
token,
|
||||
])
|
||||
|
||||
useMemo(() => {
|
||||
if (!isTauri() && isKclLspServerReady && kclLspClient && codeManager.code) {
|
||||
kclLspClient.textDocumentDidOpen({
|
||||
textDocument: {
|
||||
uri: `file:///${PROJECT_ENTRYPOINT}`,
|
||||
languageId: 'kcl',
|
||||
version: 1,
|
||||
text: codeManager.code,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [kclLspClient, isKclLspServerReady])
|
||||
|
||||
// Here we initialize the plugin which will start the client.
|
||||
// Now that we have multi-file support the name of the file is a dep of
|
||||
// this use memo, as well as the directory structure, which I think is
|
||||
@ -148,7 +164,7 @@ export const LspProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
let plugin = null
|
||||
if (isKclLspServerReady && !TEST && kclLspClient) {
|
||||
// Set up the lsp plugin.
|
||||
const lsp = kclLanguage({
|
||||
const lsp = new KclLanguageSupport({
|
||||
documentUri: `file:///${PROJECT_ENTRYPOINT}`,
|
||||
workspaceFolders: getWorkspaceFolders(),
|
||||
client: kclLspClient,
|
||||
@ -205,9 +221,9 @@ export const LspProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const fromServer: FromServer | Error = FromServer.create()
|
||||
if (err(fromServer)) return { lspClient: null }
|
||||
|
||||
const client = new Client(fromServer, intoServer)
|
||||
|
||||
setIsCopilotReady(true)
|
||||
const client = new Client(fromServer, intoServer, () => {
|
||||
setIsCopilotReady(true)
|
||||
})
|
||||
|
||||
const lspClient = new LanguageServerClient({
|
||||
client,
|
||||
|
@ -71,7 +71,7 @@ import { TEST } from 'env'
|
||||
import { exportFromEngine } from 'lib/exportFromEngine'
|
||||
import { Models } from '@kittycad/lib/dist/types/src'
|
||||
import toast from 'react-hot-toast'
|
||||
import { EditorSelection } from '@uiw/react-codemirror'
|
||||
import { EditorSelection, Transaction } from '@uiw/react-codemirror'
|
||||
import { CoreDumpManager } from 'lib/coredump'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { letEngineAnimateAndSyncCamAfter } from 'clientSideScene/CameraControls'
|
||||
@ -80,6 +80,7 @@ import useHotkeyWrapper from 'lib/hotkeyWrapper'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
import { err, trap } from 'lib/trap'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { modelingMachineEvent } from 'editor/manager'
|
||||
|
||||
type MachineContext<T extends AnyStateMachine> = {
|
||||
state: StateFrom<T>
|
||||
@ -281,11 +282,15 @@ export const ModelingMachineProvider = ({
|
||||
const dispatchSelection = (selection?: EditorSelection) => {
|
||||
if (!selection) return // TODO less of hack for the below please
|
||||
if (!editorManager.editorView) return
|
||||
editorManager.lastSelectionEvent = Date.now()
|
||||
setTimeout(() => {
|
||||
if (editorManager.editorView) {
|
||||
editorManager.editorView.dispatch({ selection })
|
||||
}
|
||||
if (!editorManager.editorView) return
|
||||
editorManager.editorView.dispatch({
|
||||
selection,
|
||||
annotations: [
|
||||
modelingMachineEvent,
|
||||
Transaction.addToHistory.of(false),
|
||||
],
|
||||
})
|
||||
})
|
||||
}
|
||||
let selections: Selections = {
|
||||
@ -328,11 +333,6 @@ export const ModelingMachineProvider = ({
|
||||
)
|
||||
updateSceneObjectColors()
|
||||
|
||||
// side effect to stop code mirror from updating the same selections again
|
||||
editorManager.lastSelection = selections.codeBasedSelections
|
||||
.map(({ range }) => `${range[1]}->${range[1]}`)
|
||||
.join('&')
|
||||
|
||||
return {
|
||||
selectionRanges: selections,
|
||||
}
|
||||
|
@ -84,6 +84,10 @@ export const KclEditorPane = () => {
|
||||
|
||||
const textWrapping = context.textEditor.textWrapping
|
||||
const cursorBlinking = context.textEditor.blinkingCursor
|
||||
// DO NOT ADD THE CODEMIRROR HOTKEYS HERE TO THE DEPENDENCY ARRAY
|
||||
// It reloads the editor every time we do _anything_ in the editor
|
||||
// I have no idea why.
|
||||
// Instead, hot load hotkeys via code mirror native.
|
||||
const codeMirrorHotkeys = codeManager.getCodemirrorHotkeys()
|
||||
|
||||
const editorExtensions = useMemo(() => {
|
||||
@ -134,7 +138,6 @@ export const KclEditorPane = () => {
|
||||
highlightSelectionMatches(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
rectangularSelection(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
interact({
|
||||
rules: [
|
||||
@ -173,13 +176,7 @@ export const KclEditorPane = () => {
|
||||
}
|
||||
|
||||
return extensions
|
||||
}, [
|
||||
kclLSP,
|
||||
copilotLSP,
|
||||
textWrapping.current,
|
||||
cursorBlinking.current,
|
||||
codeMirrorHotkeys,
|
||||
])
|
||||
}, [kclLSP, copilotLSP, textWrapping.current, cursorBlinking.current])
|
||||
|
||||
const initialCode = useRef(codeManager.code)
|
||||
|
||||
@ -192,9 +189,9 @@ export const KclEditorPane = () => {
|
||||
value={initialCode.current}
|
||||
extensions={editorExtensions}
|
||||
theme={theme}
|
||||
onCreateEditor={(_editorView) =>
|
||||
onCreateEditor={(_editorView) => {
|
||||
editorManager.setEditorView(_editorView)
|
||||
}
|
||||
}}
|
||||
indentWithTab={false}
|
||||
basicSetup={false}
|
||||
/>
|
||||
|
@ -1,13 +1,25 @@
|
||||
import { hasNextSnippetField } from '@codemirror/autocomplete'
|
||||
import { EditorView, ViewUpdate } from '@codemirror/view'
|
||||
import { EditorSelection, SelectionRange } from '@codemirror/state'
|
||||
import { engineCommandManager, sceneInfra } from 'lib/singletons'
|
||||
import { EditorSelection, Annotation, Transaction } from '@codemirror/state'
|
||||
import { engineCommandManager } from 'lib/singletons'
|
||||
import { ModelingMachineEvent } from 'machines/modelingMachine'
|
||||
import { Selections, processCodeMirrorRanges, Selection } from 'lib/selections'
|
||||
import { undo, redo } from '@codemirror/commands'
|
||||
import { CommandBarMachineEvent } from 'machines/commandBarMachine'
|
||||
import { addLineHighlight } from './highlightextension'
|
||||
import { forEachDiagnostic, setDiagnostics, Diagnostic } from '@codemirror/lint'
|
||||
import {
|
||||
forEachDiagnostic,
|
||||
Diagnostic,
|
||||
setDiagnosticsEffect,
|
||||
} from '@codemirror/lint'
|
||||
|
||||
const updateOutsideEditorAnnotation = Annotation.define<null>()
|
||||
export const updateOutsideEditorEvent = updateOutsideEditorAnnotation.of(null)
|
||||
|
||||
const modelingMachineAnnotation = Annotation.define<null>()
|
||||
export const modelingMachineEvent = modelingMachineAnnotation.of(null)
|
||||
|
||||
const setDiagnosticsAnnotation = Annotation.define<null>()
|
||||
export const setDiagnosticsEvent = setDiagnosticsAnnotation.of(null)
|
||||
|
||||
function diagnosticIsEqual(d1: Diagnostic, d2: Diagnostic): boolean {
|
||||
return d1.from === d2.from && d1.to === d2.to && d1.message === d2.message
|
||||
@ -22,8 +34,6 @@ export default class EditorManager {
|
||||
codeBasedSelections: [],
|
||||
}
|
||||
|
||||
private _lastSelectionEvent: number | null = null
|
||||
lastSelection: string = ''
|
||||
private _lastEvent: { event: string; time: number } | null = null
|
||||
|
||||
private _modelingSend: (eventInfo: ModelingMachineEvent) => void = () => {}
|
||||
@ -57,10 +67,6 @@ export default class EditorManager {
|
||||
this._selectionRanges = selectionRanges
|
||||
}
|
||||
|
||||
set lastSelectionEvent(time: number) {
|
||||
this._lastSelectionEvent = time
|
||||
}
|
||||
|
||||
set modelingSend(send: (eventInfo: ModelingMachineEvent) => void) {
|
||||
this._modelingSend = send
|
||||
}
|
||||
@ -83,32 +89,38 @@ export default class EditorManager {
|
||||
|
||||
setHighlightRange(selection: Selection['range']): void {
|
||||
this._highlightRange = selection
|
||||
const editorView = this.editorView
|
||||
const safeEnd = Math.min(
|
||||
selection[1],
|
||||
editorView?.state.doc.length || selection[1]
|
||||
this._editorView?.state.doc.length || selection[1]
|
||||
)
|
||||
if (editorView) {
|
||||
editorView.dispatch({
|
||||
if (this._editorView) {
|
||||
this._editorView.dispatch({
|
||||
effects: addLineHighlight.of([selection[0], safeEnd]),
|
||||
annotations: [
|
||||
updateOutsideEditorEvent,
|
||||
Transaction.addToHistory.of(false),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
clearDiagnostics(): void {
|
||||
if (!this.editorView) return
|
||||
this.editorView.dispatch(setDiagnostics(this.editorView.state, []))
|
||||
this.setDiagnostics([])
|
||||
}
|
||||
|
||||
setDiagnostics(diagnostics: Diagnostic[]): void {
|
||||
if (!this.editorView) return
|
||||
this.editorView.dispatch(setDiagnostics(this.editorView.state, diagnostics))
|
||||
if (!this._editorView) return
|
||||
|
||||
this._editorView.dispatch({
|
||||
effects: [setDiagnosticsEffect.of(diagnostics)],
|
||||
annotations: [setDiagnosticsEvent, Transaction.addToHistory.of(false)],
|
||||
})
|
||||
}
|
||||
|
||||
addDiagnostics(diagnostics: Diagnostic[]): void {
|
||||
if (!this.editorView) return
|
||||
if (!this._editorView) return
|
||||
|
||||
forEachDiagnostic(this.editorView.state, function (diag) {
|
||||
forEachDiagnostic(this._editorView.state, function (diag) {
|
||||
diagnostics.push(diag)
|
||||
})
|
||||
|
||||
@ -122,9 +134,7 @@ export default class EditorManager {
|
||||
uniqueDiagnostics.add(diagnostic)
|
||||
})
|
||||
|
||||
this.editorView.dispatch(
|
||||
setDiagnostics(this.editorView.state, [...uniqueDiagnostics])
|
||||
)
|
||||
this.setDiagnostics([...uniqueDiagnostics])
|
||||
}
|
||||
|
||||
undo() {
|
||||
@ -174,50 +184,33 @@ export default class EditorManager {
|
||||
].range[1]
|
||||
)
|
||||
)
|
||||
if (!this.editorView) {
|
||||
|
||||
if (!this._editorView) {
|
||||
return
|
||||
}
|
||||
this.editorView.dispatch({
|
||||
|
||||
this._editorView.dispatch({
|
||||
selection: EditorSelection.create(codeBasedSelections, 1),
|
||||
annotations: [
|
||||
updateOutsideEditorEvent,
|
||||
Transaction.addToHistory.of(false),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// We will ONLY get here if the user called a select event.
|
||||
// This is handled by the code mirror kcl plugin.
|
||||
// If you call this function from somewhere else, you best know wtf you are
|
||||
// doing. (jess)
|
||||
handleOnViewUpdate(viewUpdate: ViewUpdate): void {
|
||||
// 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.
|
||||
if (hasNextSnippetField(viewUpdate.view.state)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.editorView === null) {
|
||||
if (!this._editorView) {
|
||||
this.setEditorView(viewUpdate.view)
|
||||
}
|
||||
const selString = stringifyRanges(
|
||||
viewUpdate?.state?.selection?.ranges || []
|
||||
)
|
||||
|
||||
if (selString === this.lastSelection) {
|
||||
// onUpdate is noisy and is fired a lot by extensions
|
||||
// since we're only interested in selections changes we can ignore most of these.
|
||||
const ranges = viewUpdate?.state?.selection?.ranges || []
|
||||
if (ranges.length === 0) {
|
||||
return
|
||||
}
|
||||
// note this is also set from the "Set selection" action to stop code mirror from updating selections right after
|
||||
// selections are made from the scene
|
||||
this.lastSelection = selString
|
||||
|
||||
if (
|
||||
this._lastSelectionEvent &&
|
||||
Date.now() - this._lastSelectionEvent < 150
|
||||
) {
|
||||
return // update triggered by scene selection
|
||||
}
|
||||
|
||||
if (sceneInfra.selected) {
|
||||
return // mid drag
|
||||
}
|
||||
|
||||
const ignoreEvents: ModelingMachineEvent['type'][] = [
|
||||
'Equip Line tool',
|
||||
@ -266,7 +259,3 @@ export default class EditorManager {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyRanges(ranges: readonly SelectionRange[]): string {
|
||||
return ranges.map(({ to, from }) => `${to}->${from}`).join('&')
|
||||
}
|
||||
|
@ -67,8 +67,13 @@ export default class Client extends jsrpc.JSONRPCServerAndClient {
|
||||
#fromServer: FromServer
|
||||
private serverCapabilities: LSP.ServerCapabilities<any> = {}
|
||||
private notifyFn: ((message: LSP.NotificationMessage) => void) | null = null
|
||||
private initializedCallback: () => void
|
||||
|
||||
constructor(fromServer: FromServer, intoServer: IntoServer) {
|
||||
constructor(
|
||||
fromServer: FromServer,
|
||||
intoServer: IntoServer,
|
||||
initializedCallback: () => void
|
||||
) {
|
||||
super(
|
||||
new jsrpc.JSONRPCServer(),
|
||||
new jsrpc.JSONRPCClient(async (json: jsrpc.JSONRPCRequest) => {
|
||||
@ -82,6 +87,7 @@ export default class Client extends jsrpc.JSONRPCServerAndClient {
|
||||
})
|
||||
)
|
||||
this.#fromServer = fromServer
|
||||
this.initializedCallback = initializedCallback
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@ -163,6 +169,8 @@ export default class Client extends jsrpc.JSONRPCServerAndClient {
|
||||
// notify "initialized": client --> server
|
||||
this.notify(LSP.InitializedNotification.type.method, {})
|
||||
|
||||
this.initializedCallback()
|
||||
|
||||
await Promise.all(
|
||||
this.afterInitializedHooks.map((f: () => Promise<void>) => f())
|
||||
)
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
PluginValue,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
} from '@codemirror/view'
|
||||
@ -11,7 +12,6 @@ import {
|
||||
Annotation,
|
||||
EditorState,
|
||||
Extension,
|
||||
Prec,
|
||||
StateEffect,
|
||||
StateField,
|
||||
Transaction,
|
||||
@ -19,15 +19,30 @@ import {
|
||||
import { completionStatus } from '@codemirror/autocomplete'
|
||||
import { offsetToPos, posToOffset } from 'editor/plugins/lsp/util'
|
||||
import { LanguageServerOptions, LanguageServerClient } from 'editor/plugins/lsp'
|
||||
import { deferExecution } from 'lib/utils'
|
||||
import {
|
||||
LanguageServerPlugin,
|
||||
documentUri,
|
||||
TransactionAnnotation,
|
||||
docPathFacet,
|
||||
languageId,
|
||||
updateInfo,
|
||||
workspaceFolders,
|
||||
RelevantUpdate,
|
||||
} from 'editor/plugins/lsp/plugin'
|
||||
|
||||
const copilotPluginAnnotation = Annotation.define<null>()
|
||||
export const copilotPluginEvent = copilotPluginAnnotation.of(null)
|
||||
|
||||
// Effects to tell StateEffect what to do with GhostText
|
||||
const addSuggestion = StateEffect.define<Suggestion>()
|
||||
const acceptSuggestion = StateEffect.define<null>()
|
||||
const clearSuggestion = StateEffect.define<null>()
|
||||
const typeFirst = StateEffect.define<number>()
|
||||
|
||||
const ghostMark = Decoration.mark({ class: 'cm-ghostText' })
|
||||
|
||||
const changesDelay = 600
|
||||
|
||||
interface Suggestion {
|
||||
text: string
|
||||
displayText: string
|
||||
@ -38,15 +53,10 @@ interface Suggestion {
|
||||
uuid: string
|
||||
}
|
||||
|
||||
// Effects to tell StateEffect what to do with GhostText
|
||||
const addSuggestion = StateEffect.define<Suggestion>()
|
||||
const acceptSuggestion = StateEffect.define<null>()
|
||||
const clearSuggestion = StateEffect.define<null>()
|
||||
const typeFirst = StateEffect.define<number>()
|
||||
|
||||
interface CompletionState {
|
||||
ghostText: GhostText | null
|
||||
}
|
||||
|
||||
interface GhostText {
|
||||
text: string
|
||||
displayText: string
|
||||
@ -65,6 +75,16 @@ export const completionDecoration = StateField.define<CompletionState>({
|
||||
return { ghostText: null }
|
||||
},
|
||||
update(state: CompletionState, transaction: Transaction) {
|
||||
// We only care about events from this plugin.
|
||||
if (transaction.annotation(copilotPluginEvent.type) === undefined) {
|
||||
return state
|
||||
}
|
||||
|
||||
// We only care about transactions with effects.
|
||||
if (!transaction.effects) {
|
||||
return state
|
||||
}
|
||||
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(addSuggestion)) {
|
||||
// When adding a suggestion, we set th ghostText
|
||||
@ -160,126 +180,376 @@ export const completionDecoration = StateField.define<CompletionState>({
|
||||
),
|
||||
})
|
||||
|
||||
const copilotEvent = Annotation.define<null>()
|
||||
export const relevantUpdate = (update: ViewUpdate): RelevantUpdate => {
|
||||
const infos = updateInfo(update)
|
||||
|
||||
/****************************************************************************
|
||||
************************* COMMANDS ******************************************
|
||||
*****************************************************************************/
|
||||
|
||||
const acceptSuggestionCommand = (
|
||||
copilotClient: LanguageServerClient,
|
||||
view: EditorView
|
||||
) => {
|
||||
// We delete the ghost text and insert the suggestion.
|
||||
// We also set the cursor to the end of the suggestion.
|
||||
const ghostText = view.state.field(completionDecoration)!.ghostText
|
||||
if (!ghostText) {
|
||||
return false
|
||||
// Make sure we are not in a snippet
|
||||
if (infos.some((info) => info.inSnippet)) {
|
||||
return {
|
||||
overall: false,
|
||||
userSelect: false,
|
||||
time: null,
|
||||
}
|
||||
}
|
||||
|
||||
const ghostTextStart = ghostText.displayPos
|
||||
const ghostTextEnd = ghostText.endGhostText
|
||||
|
||||
const actualTextStart = ghostText.startPos
|
||||
const actualTextEnd = ghostText.endPos
|
||||
|
||||
const replacementEnd = ghostText.endReplacement
|
||||
|
||||
const suggestion = ghostText.text
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: ghostTextStart,
|
||||
to: ghostTextEnd,
|
||||
insert: '',
|
||||
},
|
||||
// selection: {anchor: actualTextEnd},
|
||||
effects: acceptSuggestion.of(null),
|
||||
annotations: [copilotEvent.of(null), Transaction.addToHistory.of(false)],
|
||||
})
|
||||
|
||||
const tmpTextEnd = replacementEnd - (ghostTextEnd - ghostTextStart)
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: actualTextStart,
|
||||
to: tmpTextEnd,
|
||||
insert: suggestion,
|
||||
},
|
||||
selection: { anchor: actualTextEnd },
|
||||
annotations: [copilotEvent.of(null), Transaction.addToHistory.of(true)],
|
||||
})
|
||||
|
||||
copilotClient.accept(ghostText.uuid)
|
||||
return true
|
||||
}
|
||||
export const rejectSuggestionCommand = (
|
||||
copilotClient: LanguageServerClient,
|
||||
view: EditorView
|
||||
) => {
|
||||
// We delete the suggestion, then carry through with the original keypress
|
||||
const ghostText = view.state.field(completionDecoration)!.ghostText
|
||||
if (!ghostText) {
|
||||
return false
|
||||
return {
|
||||
overall: infos.some(
|
||||
(info) =>
|
||||
update.focusChanged ||
|
||||
info.annotations.includes(TransactionAnnotation.UserSelect) ||
|
||||
info.annotations.includes(TransactionAnnotation.UserInput) ||
|
||||
info.annotations.includes(TransactionAnnotation.UserDelete) ||
|
||||
info.annotations.includes(TransactionAnnotation.UserUndo) ||
|
||||
info.annotations.includes(TransactionAnnotation.UserRedo) ||
|
||||
info.annotations.includes(TransactionAnnotation.UserMove) ||
|
||||
info.annotations.includes(TransactionAnnotation.Copoilot)
|
||||
),
|
||||
userSelect: infos.some((info) =>
|
||||
info.annotations.includes(TransactionAnnotation.UserSelect)
|
||||
),
|
||||
time: infos.length ? infos[0].time : null,
|
||||
}
|
||||
|
||||
const ghostTextStart = ghostText.displayPos
|
||||
const ghostTextEnd = ghostText.endGhostText
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: ghostTextStart,
|
||||
to: ghostTextEnd,
|
||||
insert: '',
|
||||
},
|
||||
effects: clearSuggestion.of(null),
|
||||
annotations: [copilotEvent.of(null), Transaction.addToHistory.of(false)],
|
||||
})
|
||||
|
||||
copilotClient.reject()
|
||||
return false
|
||||
}
|
||||
|
||||
const sameKeyCommand = (
|
||||
copilotClient: LanguageServerClient,
|
||||
view: EditorView,
|
||||
key: string
|
||||
) => {
|
||||
// When we type a key that is the same as the first letter of the suggestion, we delete the first letter of the suggestion and carry through with the original keypress
|
||||
const ghostText = view.state.field(completionDecoration)!.ghostText
|
||||
if (!ghostText) {
|
||||
return false
|
||||
}
|
||||
const ghostTextStart = ghostText.displayPos
|
||||
const indent = view.state.facet(indentUnit)
|
||||
// A view plugin that requests completions from the server after a delay
|
||||
export class CompletionRequester implements PluginValue {
|
||||
private client: LanguageServerClient
|
||||
private lastPos: number = 0
|
||||
private viewUpdate: ViewUpdate | null = null
|
||||
|
||||
if (key === 'Tab' && ghostText.displayText.startsWith(indent)) {
|
||||
view.dispatch({
|
||||
selection: { anchor: ghostTextStart + indent.length },
|
||||
effects: typeFirst.of(indent.length),
|
||||
annotations: [copilotEvent.of(null), Transaction.addToHistory.of(false)],
|
||||
})
|
||||
return true
|
||||
} else if (key === 'Tab') {
|
||||
return acceptSuggestionCommand(copilotClient, view)
|
||||
} else if (ghostText.weirdInsert || key !== ghostText.displayText[0]) {
|
||||
return rejectSuggestionCommand(copilotClient, view)
|
||||
} else if (ghostText.displayText.length === 1) {
|
||||
return acceptSuggestionCommand(copilotClient, view)
|
||||
} else {
|
||||
// Use this to delete the first letter of the suggestion
|
||||
view.dispatch({
|
||||
selection: { anchor: ghostTextStart + 1 },
|
||||
effects: typeFirst.of(1),
|
||||
annotations: [copilotEvent.of(null), Transaction.addToHistory.of(false)],
|
||||
private _deffererCodeUpdate = deferExecution(() => {
|
||||
if (this.viewUpdate === null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.requestCompletions()
|
||||
}, changesDelay)
|
||||
|
||||
private _deffererUserSelect = deferExecution(() => {
|
||||
if (this.viewUpdate === null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.rejectSuggestionCommand()
|
||||
}, changesDelay)
|
||||
|
||||
constructor(client: LanguageServerClient) {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
update(viewUpdate: ViewUpdate) {
|
||||
this.viewUpdate = viewUpdate
|
||||
|
||||
const isRelevant = relevantUpdate(viewUpdate)
|
||||
if (!isRelevant.overall) {
|
||||
return
|
||||
}
|
||||
|
||||
// If we have a user select event, we want to clear the ghost text.
|
||||
if (isRelevant.userSelect) {
|
||||
this._deffererUserSelect(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (viewUpdate.focusChanged) {
|
||||
this.rejectSuggestionCommand()
|
||||
return
|
||||
}
|
||||
|
||||
if (!viewUpdate.docChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
this.lastPos = this.viewUpdate.state.selection.main.head
|
||||
this._deffererCodeUpdate(true)
|
||||
}
|
||||
|
||||
ghostText(): GhostText | null {
|
||||
if (!this.viewUpdate) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
this.viewUpdate.view.state.field(completionDecoration)?.ghostText || null
|
||||
)
|
||||
}
|
||||
|
||||
containsGhostText(): boolean {
|
||||
return this.ghostText() !== null
|
||||
}
|
||||
|
||||
autocompleting(): boolean {
|
||||
if (!this.viewUpdate) {
|
||||
return false
|
||||
}
|
||||
|
||||
return completionStatus(this.viewUpdate.state) === 'active'
|
||||
}
|
||||
|
||||
notFocused(): boolean {
|
||||
if (!this.viewUpdate) {
|
||||
return true
|
||||
}
|
||||
|
||||
return !this.viewUpdate.view.hasFocus
|
||||
}
|
||||
|
||||
async requestCompletions(): Promise<void> {
|
||||
if (
|
||||
this.viewUpdate === null ||
|
||||
this.containsGhostText() ||
|
||||
this.autocompleting() ||
|
||||
this.notFocused() ||
|
||||
!this.viewUpdate.docChanged
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const pos = this.viewUpdate.state.selection.main.head
|
||||
|
||||
// Check if the position has changed
|
||||
if (pos !== this.lastPos) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the current position and source
|
||||
const state = this.viewUpdate.state
|
||||
const dUri = state.facet(docPathFacet)
|
||||
|
||||
// Request completion from the server
|
||||
const completionResult = await this.client.getCompletion({
|
||||
doc: {
|
||||
source: state.doc.toString(),
|
||||
tabSize: state.facet(EditorState.tabSize),
|
||||
indentSize: 1,
|
||||
insertSpaces: true,
|
||||
path: dUri.split('/').pop()!,
|
||||
uri: dUri,
|
||||
relativePath: dUri.replace('file://', ''),
|
||||
languageId: state.facet(languageId),
|
||||
position: offsetToPos(state.doc, pos),
|
||||
},
|
||||
})
|
||||
|
||||
if (completionResult.completions.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let {
|
||||
text,
|
||||
displayText,
|
||||
range: { start },
|
||||
position,
|
||||
uuid,
|
||||
} = completionResult.completions[0]
|
||||
|
||||
if (text.length === 0 || displayText.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const startPos = posToOffset(state.doc, {
|
||||
line: start.line,
|
||||
character: start.character,
|
||||
})
|
||||
|
||||
if (startPos === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const endGhostOffset = posToOffset(state.doc, {
|
||||
line: position.line,
|
||||
character: position.character,
|
||||
})
|
||||
if (endGhostOffset === undefined) {
|
||||
return
|
||||
}
|
||||
const endGhostPos = endGhostOffset + displayText.length
|
||||
// EndPos is the position that marks the complete end
|
||||
// of what is to be replaced when we accept a completion
|
||||
// result
|
||||
const endPos = startPos + text.length
|
||||
|
||||
// Check if they changed position.
|
||||
if (pos !== this.lastPos) {
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure we are not currently completing.
|
||||
if (this.autocompleting() || this.notFocused()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dispatch an effect to add the suggestion
|
||||
// If the completion starts before the end of the line, check the end of the line with the end of the completion.
|
||||
const line = this.viewUpdate.view.state.doc.lineAt(pos)
|
||||
if (line.to !== pos) {
|
||||
const ending = this.viewUpdate.view.state.doc.sliceString(pos, line.to)
|
||||
if (displayText.endsWith(ending)) {
|
||||
displayText = displayText.slice(0, displayText.length - ending.length)
|
||||
} else if (displayText.includes(ending)) {
|
||||
// Remove the ending
|
||||
this.viewUpdate.view.dispatch({
|
||||
changes: {
|
||||
from: pos,
|
||||
to: line.to,
|
||||
insert: '',
|
||||
},
|
||||
selection: { anchor: pos },
|
||||
effects: typeFirst.of(ending.length),
|
||||
annotations: [copilotPluginEvent, Transaction.addToHistory.of(false)],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.viewUpdate.view.dispatch({
|
||||
changes: {
|
||||
from: pos,
|
||||
to: pos,
|
||||
insert: displayText,
|
||||
},
|
||||
effects: [
|
||||
addSuggestion.of({
|
||||
displayText,
|
||||
endReplacement: endGhostPos,
|
||||
text,
|
||||
cursorPos: pos,
|
||||
startPos,
|
||||
endPos,
|
||||
uuid,
|
||||
}),
|
||||
],
|
||||
annotations: [copilotPluginEvent, Transaction.addToHistory.of(false)],
|
||||
})
|
||||
|
||||
this.lastPos = pos
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
acceptSuggestionCommand(): boolean {
|
||||
if (!this.viewUpdate) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ghostText = this.ghostText()
|
||||
if (!ghostText) {
|
||||
return false
|
||||
}
|
||||
|
||||
// We delete the ghost text and insert the suggestion.
|
||||
// We also set the cursor to the end of the suggestion.
|
||||
const ghostTextStart = ghostText.displayPos
|
||||
const ghostTextEnd = ghostText.endGhostText
|
||||
|
||||
const actualTextStart = ghostText.startPos
|
||||
const actualTextEnd = ghostText.endPos
|
||||
|
||||
const replacementEnd = ghostText.endReplacement
|
||||
|
||||
const suggestion = ghostText.text
|
||||
|
||||
this.viewUpdate.view.dispatch({
|
||||
changes: {
|
||||
from: ghostTextStart,
|
||||
to: ghostTextEnd,
|
||||
insert: '',
|
||||
},
|
||||
effects: acceptSuggestion.of(null),
|
||||
annotations: [copilotPluginEvent, Transaction.addToHistory.of(false)],
|
||||
})
|
||||
|
||||
const tmpTextEnd = replacementEnd - (ghostTextEnd - ghostTextStart)
|
||||
|
||||
this.viewUpdate.view.dispatch({
|
||||
changes: {
|
||||
from: actualTextStart,
|
||||
to: tmpTextEnd,
|
||||
insert: suggestion,
|
||||
},
|
||||
selection: { anchor: actualTextEnd },
|
||||
annotations: [copilotPluginEvent, Transaction.addToHistory.of(true)],
|
||||
})
|
||||
|
||||
this.client.accept(ghostText.uuid)
|
||||
return true
|
||||
}
|
||||
|
||||
rejectSuggestionCommand(): boolean {
|
||||
if (!this.viewUpdate) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ghostText = this.ghostText()
|
||||
if (!ghostText) {
|
||||
return false
|
||||
}
|
||||
|
||||
// We delete the suggestion, then carry through with the original keypress
|
||||
const ghostTextStart = ghostText.displayPos
|
||||
const ghostTextEnd = ghostText.endGhostText
|
||||
|
||||
this.viewUpdate.view.dispatch({
|
||||
changes: {
|
||||
from: ghostTextStart,
|
||||
to: ghostTextEnd,
|
||||
insert: '',
|
||||
},
|
||||
effects: clearSuggestion.of(null),
|
||||
annotations: [copilotPluginEvent, Transaction.addToHistory.of(false)],
|
||||
})
|
||||
|
||||
this.client.reject()
|
||||
return false
|
||||
}
|
||||
|
||||
sameKeyCommand(key: string) {
|
||||
if (!this.viewUpdate) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ghostText = this.ghostText()
|
||||
if (!ghostText) {
|
||||
return false
|
||||
}
|
||||
|
||||
const tabKey = 'Tab'
|
||||
|
||||
// When we type a key that is the same as the first letter of the suggestion, we delete the first letter of the suggestion and carry through with the original keypress
|
||||
const ghostTextStart = ghostText.displayPos
|
||||
const indent = this.viewUpdate.view.state.facet(indentUnit)
|
||||
|
||||
if (key === tabKey && ghostText.displayText.startsWith(indent)) {
|
||||
this.viewUpdate.view.dispatch({
|
||||
selection: { anchor: ghostTextStart + indent.length },
|
||||
effects: typeFirst.of(indent.length),
|
||||
annotations: [copilotPluginEvent, Transaction.addToHistory.of(false)],
|
||||
})
|
||||
return true
|
||||
} else if (key === tabKey) {
|
||||
return this.acceptSuggestionCommand()
|
||||
} else if (ghostText.weirdInsert || key !== ghostText.displayText[0]) {
|
||||
return this.rejectSuggestionCommand()
|
||||
} else if (ghostText.displayText.length === 1) {
|
||||
return this.acceptSuggestionCommand()
|
||||
} else {
|
||||
// Use this to delete the first letter of the suggestion
|
||||
this.viewUpdate.view.dispatch({
|
||||
selection: { anchor: ghostTextStart + 1 },
|
||||
effects: typeFirst.of(1),
|
||||
annotations: [copilotPluginEvent, Transaction.addToHistory.of(false)],
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const completionPlugin = (copilotClient: LanguageServerClient) =>
|
||||
EditorView.domEventHandlers({
|
||||
export const copilotPlugin = (options: LanguageServerOptions): Extension => {
|
||||
const completionPlugin = ViewPlugin.define((view) => {
|
||||
return new CompletionRequester(options.client)
|
||||
})
|
||||
|
||||
const domHandlers = EditorView.domEventHandlers({
|
||||
keydown(event, view) {
|
||||
if (
|
||||
event.key !== 'Shift' &&
|
||||
@ -287,204 +557,29 @@ const completionPlugin = (copilotClient: LanguageServerClient) =>
|
||||
event.key !== 'Alt' &&
|
||||
event.key !== 'Meta'
|
||||
) {
|
||||
return sameKeyCommand(copilotClient, view, event.key)
|
||||
if (view.plugin === null) return false
|
||||
|
||||
// Get the current plugin from the map.
|
||||
const p = view.plugin(completionPlugin)
|
||||
if (p === null) return false
|
||||
|
||||
return p.sameKeyCommand(event.key)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
mousedown(event, view) {
|
||||
return rejectSuggestionCommand(copilotClient, view)
|
||||
},
|
||||
})
|
||||
|
||||
const viewCompletionPlugin = (copilotClient: LanguageServerClient) =>
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.focusChanged) {
|
||||
rejectSuggestionCommand(copilotClient, update.view)
|
||||
}
|
||||
})
|
||||
// A view plugin that requests completions from the server after a delay
|
||||
const completionRequester = (client: LanguageServerClient) => {
|
||||
let timeout: any = null
|
||||
let lastPos = 0
|
||||
|
||||
const badUpdate = (update: ViewUpdate) => {
|
||||
for (const tr of update.transactions) {
|
||||
if (tr.annotation(copilotEvent) !== undefined) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
const containsGhostText = (update: ViewUpdate) => {
|
||||
return update.state.field(completionDecoration).ghostText != null
|
||||
}
|
||||
const autocompleting = (update: ViewUpdate) => {
|
||||
return completionStatus(update.state) === 'active'
|
||||
}
|
||||
const notFocused = (update: ViewUpdate) => {
|
||||
return !update.view.hasFocus
|
||||
}
|
||||
|
||||
return EditorView.updateListener.of((update: ViewUpdate) => {
|
||||
if (
|
||||
update.docChanged &&
|
||||
!update.transactions.some((tr) =>
|
||||
tr.effects.some((e) => e.is(acceptSuggestion) || e.is(clearSuggestion))
|
||||
)
|
||||
) {
|
||||
// Cancel the previous timeout
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
if (
|
||||
badUpdate(update) ||
|
||||
containsGhostText(update) ||
|
||||
autocompleting(update) ||
|
||||
notFocused(update)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the current position and source
|
||||
const state = update.state
|
||||
const pos = state.selection.main.head
|
||||
const source = state.doc.toString()
|
||||
|
||||
const dUri = state.facet(documentUri)
|
||||
const path = dUri.split('/').pop()!
|
||||
const relativePath = dUri.replace('file://', '')
|
||||
|
||||
// Set a new timeout to request completion
|
||||
timeout = setTimeout(async () => {
|
||||
// Check if the position has changed
|
||||
if (pos === lastPos) {
|
||||
// Request completion from the server
|
||||
try {
|
||||
const completionResult = await client.getCompletion({
|
||||
doc: {
|
||||
source,
|
||||
tabSize: state.facet(EditorState.tabSize),
|
||||
indentSize: 1,
|
||||
insertSpaces: true,
|
||||
path,
|
||||
uri: dUri,
|
||||
relativePath,
|
||||
languageId: state.facet(languageId),
|
||||
position: offsetToPos(state.doc, pos),
|
||||
},
|
||||
})
|
||||
|
||||
if (completionResult.completions.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let {
|
||||
text,
|
||||
displayText,
|
||||
range: { start },
|
||||
position,
|
||||
uuid,
|
||||
} = completionResult.completions[0]
|
||||
|
||||
const startPos = posToOffset(state.doc, {
|
||||
line: start.line,
|
||||
character: start.character,
|
||||
})!
|
||||
|
||||
const endGhostPos =
|
||||
posToOffset(state.doc, {
|
||||
line: position.line,
|
||||
character: position.character,
|
||||
})! + displayText.length
|
||||
// EndPos is the position that marks the complete end
|
||||
// of what is to be replaced when we accept a completion
|
||||
// result
|
||||
const endPos = startPos + text.length
|
||||
|
||||
// Check if the position is still the same
|
||||
if (
|
||||
pos === lastPos &&
|
||||
completionStatus(update.view.state) !== 'active' &&
|
||||
update.view.hasFocus
|
||||
) {
|
||||
// Dispatch an effect to add the suggestion
|
||||
// If the completion starts before the end of the line, check the end of the line with the end of the completion
|
||||
const line = update.view.state.doc.lineAt(pos)
|
||||
if (line.to !== pos) {
|
||||
const ending = update.view.state.doc.sliceString(pos, line.to)
|
||||
if (displayText.endsWith(ending)) {
|
||||
displayText = displayText.slice(
|
||||
0,
|
||||
displayText.length - ending.length
|
||||
)
|
||||
} else if (displayText.includes(ending)) {
|
||||
// Remove the ending
|
||||
update.view.dispatch({
|
||||
changes: {
|
||||
from: pos,
|
||||
to: line.to,
|
||||
insert: '',
|
||||
},
|
||||
selection: { anchor: pos },
|
||||
effects: typeFirst.of(ending.length),
|
||||
annotations: [
|
||||
copilotEvent.of(null),
|
||||
Transaction.addToHistory.of(false),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
update.view.dispatch({
|
||||
changes: {
|
||||
from: pos,
|
||||
to: pos,
|
||||
insert: displayText,
|
||||
},
|
||||
effects: [
|
||||
addSuggestion.of({
|
||||
displayText,
|
||||
endReplacement: endGhostPos,
|
||||
text,
|
||||
cursorPos: pos,
|
||||
startPos,
|
||||
endPos,
|
||||
uuid,
|
||||
}),
|
||||
],
|
||||
annotations: [
|
||||
copilotEvent.of(null),
|
||||
Transaction.addToHistory.of(false),
|
||||
],
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('copilot completion failed', error)
|
||||
// Javascript wait for 500ms for some reason is necessary here.
|
||||
// TODO - FIGURE OUT WHY THIS RESOLVES THE BUG
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
}
|
||||
}
|
||||
}, 150)
|
||||
// Update the last position
|
||||
lastPos = pos
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const copilotPlugin = (options: LanguageServerOptions): Extension => {
|
||||
return [
|
||||
documentUri.of(options.documentUri),
|
||||
docPathFacet.of(options.documentUri),
|
||||
languageId.of('kcl'),
|
||||
workspaceFolders.of(options.workspaceFolders),
|
||||
ViewPlugin.define(
|
||||
(view) =>
|
||||
new LanguageServerPlugin(options.client, view, options.allowHTMLContent)
|
||||
),
|
||||
completionPlugin,
|
||||
domHandlers,
|
||||
completionDecoration,
|
||||
Prec.highest(completionPlugin(options.client)),
|
||||
Prec.highest(viewCompletionPlugin(options.client)),
|
||||
completionRequester(options.client),
|
||||
]
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import type * as LSP from 'vscode-languageserver-protocol'
|
||||
import Client from './client'
|
||||
import { SemanticToken, deserializeTokens } from './kcl/semantic_tokens'
|
||||
import { LanguageServerPlugin } from 'editor/plugins/lsp/plugin'
|
||||
import { CopilotLspCompletionParams } from 'wasm-lib/kcl/bindings/CopilotLspCompletionParams'
|
||||
import { CopilotCompletionResponse } from 'wasm-lib/kcl/bindings/CopilotCompletionResponse'
|
||||
@ -68,7 +67,7 @@ export interface LanguageServerOptions {
|
||||
|
||||
export class LanguageServerClient {
|
||||
private client: Client
|
||||
readonly name: string
|
||||
readonly name: LspWorker
|
||||
|
||||
public ready: boolean
|
||||
|
||||
@ -76,8 +75,6 @@ export class LanguageServerClient {
|
||||
|
||||
public initializePromise: Promise<void>
|
||||
|
||||
private isUpdatingSemanticTokens: boolean = false
|
||||
private semanticTokens: SemanticToken[] = []
|
||||
private queuedUids: string[] = []
|
||||
|
||||
constructor(options: LanguageServerClientOptions) {
|
||||
@ -111,19 +108,10 @@ export class LanguageServerClient {
|
||||
|
||||
textDocumentDidOpen(params: LSP.DidOpenTextDocumentParams) {
|
||||
this.notify('textDocument/didOpen', params)
|
||||
|
||||
// Update the facet of the plugins to the correct value.
|
||||
for (const plugin of this.plugins) {
|
||||
plugin.documentUri = params.textDocument.uri
|
||||
plugin.languageId = params.textDocument.languageId
|
||||
}
|
||||
|
||||
this.updateSemanticTokens(params.textDocument.uri)
|
||||
}
|
||||
|
||||
textDocumentDidChange(params: LSP.DidChangeTextDocumentParams) {
|
||||
this.notify('textDocument/didChange', params)
|
||||
this.updateSemanticTokens(params.textDocument.uri)
|
||||
}
|
||||
|
||||
textDocumentDidClose(params: LSP.DidCloseTextDocumentParams) {
|
||||
@ -134,18 +122,9 @@ export class LanguageServerClient {
|
||||
added: LSP.WorkspaceFolder[],
|
||||
removed: LSP.WorkspaceFolder[]
|
||||
) {
|
||||
// Add all the current workspace folders in the plugin to removed.
|
||||
for (const plugin of this.plugins) {
|
||||
removed.push(...plugin.workspaceFolders)
|
||||
}
|
||||
this.notify('workspace/didChangeWorkspaceFolders', {
|
||||
event: { added, removed },
|
||||
})
|
||||
|
||||
// Add all the new workspace folders to the plugins.
|
||||
for (const plugin of this.plugins) {
|
||||
plugin.workspaceFolders = added
|
||||
}
|
||||
}
|
||||
|
||||
workspaceDidCreateFiles(params: LSP.CreateFilesParams) {
|
||||
@ -160,33 +139,13 @@ export class LanguageServerClient {
|
||||
this.notify('workspace/didDeleteFiles', params)
|
||||
}
|
||||
|
||||
async updateSemanticTokens(uri: string) {
|
||||
async textDocumentSemanticTokensFull(params: LSP.SemanticTokensParams) {
|
||||
const serverCapabilities = this.getServerCapabilities()
|
||||
if (!serverCapabilities.semanticTokensProvider) {
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure we can only run, if we aren't already running.
|
||||
if (!this.isUpdatingSemanticTokens) {
|
||||
this.isUpdatingSemanticTokens = true
|
||||
|
||||
const result = await this.request('textDocument/semanticTokens/full', {
|
||||
textDocument: {
|
||||
uri,
|
||||
},
|
||||
})
|
||||
|
||||
this.semanticTokens = await deserializeTokens(
|
||||
result.data,
|
||||
this.getServerCapabilities().semanticTokensProvider
|
||||
)
|
||||
|
||||
this.isUpdatingSemanticTokens = false
|
||||
}
|
||||
}
|
||||
|
||||
getSemanticTokens(): SemanticToken[] {
|
||||
return this.semanticTokens
|
||||
return this.request('textDocument/semanticTokens/full', params)
|
||||
}
|
||||
|
||||
async textDocumentHover(params: LSP.HoverParams) {
|
||||
|
@ -1,4 +1,14 @@
|
||||
import { autocompletion } from '@codemirror/autocomplete'
|
||||
import {
|
||||
acceptCompletion,
|
||||
autocompletion,
|
||||
clearSnippet,
|
||||
closeCompletion,
|
||||
hasNextSnippetField,
|
||||
moveCompletionSelection,
|
||||
nextSnippetField,
|
||||
prevSnippetField,
|
||||
startCompletion,
|
||||
} from '@codemirror/autocomplete'
|
||||
import { Extension, EditorState, Prec } from '@codemirror/state'
|
||||
import {
|
||||
ViewPlugin,
|
||||
@ -7,6 +17,8 @@ import {
|
||||
keymap,
|
||||
KeyBinding,
|
||||
tooltips,
|
||||
PluginValue,
|
||||
ViewUpdate,
|
||||
} from '@codemirror/view'
|
||||
import { CompletionTriggerKind } from 'vscode-languageserver-protocol'
|
||||
import { offsetToPos } from 'editor/plugins/lsp/util'
|
||||
@ -14,11 +26,18 @@ import { LanguageServerOptions } from 'editor/plugins/lsp'
|
||||
import { syntaxTree, indentService, foldService } from '@codemirror/language'
|
||||
import { linter, forEachDiagnostic, Diagnostic } from '@codemirror/lint'
|
||||
import {
|
||||
docPathFacet,
|
||||
LanguageServerPlugin,
|
||||
documentUri,
|
||||
languageId,
|
||||
workspaceFolders,
|
||||
updateInfo,
|
||||
RelevantUpdate,
|
||||
TransactionAnnotation,
|
||||
} from 'editor/plugins/lsp/plugin'
|
||||
import { deferExecution } from 'lib/utils'
|
||||
import { codeManager, editorManager, kclManager } from 'lib/singletons'
|
||||
|
||||
const changesDelay = 600
|
||||
|
||||
export const kclIndentService = () => {
|
||||
// Match the indentation of the previous line (if present).
|
||||
@ -39,6 +58,81 @@ export const kclIndentService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
export const relevantUpdate = (update: ViewUpdate): RelevantUpdate => {
|
||||
const infos = updateInfo(update)
|
||||
// Make sure we are not in a snippet
|
||||
if (infos.some((info) => info.inSnippet)) {
|
||||
return {
|
||||
overall: false,
|
||||
userSelect: false,
|
||||
time: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
overall: infos.some(
|
||||
(info) =>
|
||||
info.annotations.includes(TransactionAnnotation.UserSelect) ||
|
||||
info.annotations.includes(TransactionAnnotation.UserInput) ||
|
||||
info.annotations.includes(TransactionAnnotation.UserDelete) ||
|
||||
info.annotations.includes(TransactionAnnotation.UserUndo) ||
|
||||
info.annotations.includes(TransactionAnnotation.UserRedo) ||
|
||||
info.annotations.includes(TransactionAnnotation.UserMove)
|
||||
),
|
||||
userSelect: infos.some((info) =>
|
||||
info.annotations.includes(TransactionAnnotation.UserSelect)
|
||||
),
|
||||
time: infos.length ? infos[0].time : null,
|
||||
}
|
||||
}
|
||||
|
||||
// A view plugin that requests completions from the server after a delay
|
||||
export class KclPlugin implements PluginValue {
|
||||
private viewUpdate: ViewUpdate | null = null
|
||||
|
||||
private _deffererCodeUpdate = deferExecution(() => {
|
||||
if (this.viewUpdate === null) {
|
||||
return
|
||||
}
|
||||
|
||||
kclManager.executeCode()
|
||||
}, changesDelay)
|
||||
|
||||
private _deffererUserSelect = deferExecution(() => {
|
||||
if (this.viewUpdate === null) {
|
||||
return
|
||||
}
|
||||
|
||||
editorManager.handleOnViewUpdate(this.viewUpdate)
|
||||
}, 50)
|
||||
|
||||
update(viewUpdate: ViewUpdate) {
|
||||
this.viewUpdate = viewUpdate
|
||||
editorManager.setEditorView(viewUpdate.view)
|
||||
|
||||
const isRelevant = relevantUpdate(viewUpdate)
|
||||
if (!isRelevant.overall) {
|
||||
return
|
||||
}
|
||||
|
||||
// If we have a user select event, we want to update what parts are
|
||||
// highlighted.
|
||||
if (isRelevant.userSelect) {
|
||||
this._deffererUserSelect(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (!viewUpdate.docChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
const newCode = viewUpdate.state.doc.toString()
|
||||
codeManager.code = newCode
|
||||
codeManager.writeToFile()
|
||||
|
||||
this._deffererCodeUpdate(true)
|
||||
}
|
||||
}
|
||||
|
||||
export function kclPlugin(options: LanguageServerOptions): Extension {
|
||||
let plugin: LanguageServerPlugin | null = null
|
||||
const viewPlugin = ViewPlugin.define(
|
||||
@ -58,8 +152,8 @@ export function kclPlugin(options: LanguageServerOptions): Extension {
|
||||
|
||||
// Get the current plugin from the map.
|
||||
const p = view.plugin(viewPlugin)
|
||||
|
||||
if (p === null) return false
|
||||
|
||||
p.requestFormatting()
|
||||
return true
|
||||
},
|
||||
@ -68,6 +162,39 @@ export function kclPlugin(options: LanguageServerOptions): Extension {
|
||||
// Create an extension for the key mappings.
|
||||
const kclKeymapExt = Prec.highest(keymap.computeN([], () => [kclKeymap]))
|
||||
|
||||
const autocompleteKeymap: readonly KeyBinding[] = [
|
||||
{ key: 'Ctrl-Space', run: startCompletion },
|
||||
{
|
||||
key: 'Escape',
|
||||
run: (view: EditorView): boolean => {
|
||||
if (clearSnippet(view)) return true
|
||||
|
||||
return closeCompletion(view)
|
||||
},
|
||||
},
|
||||
{ key: 'ArrowDown', run: moveCompletionSelection(true) },
|
||||
{ key: 'ArrowUp', run: moveCompletionSelection(false) },
|
||||
{ key: 'PageDown', run: moveCompletionSelection(true, 'page') },
|
||||
{ key: 'PageUp', run: moveCompletionSelection(false, 'page') },
|
||||
{ key: 'Enter', run: acceptCompletion },
|
||||
{
|
||||
key: 'Tab',
|
||||
run: (view: EditorView): boolean => {
|
||||
if (hasNextSnippetField(view.state)) {
|
||||
const result = nextSnippetField(view)
|
||||
return result
|
||||
}
|
||||
|
||||
return acceptCompletion(view)
|
||||
},
|
||||
shift: prevSnippetField,
|
||||
},
|
||||
]
|
||||
|
||||
const autocompleteKeymapExt = Prec.highest(
|
||||
keymap.computeN([], () => [autocompleteKeymap])
|
||||
)
|
||||
|
||||
const folding = foldService.of(
|
||||
(state: EditorState, lineStart: number, lineEnd: number) => {
|
||||
if (plugin == null) return null
|
||||
@ -79,10 +206,11 @@ export function kclPlugin(options: LanguageServerOptions): Extension {
|
||||
)
|
||||
|
||||
return [
|
||||
documentUri.of(options.documentUri),
|
||||
docPathFacet.of(options.documentUri),
|
||||
languageId.of('kcl'),
|
||||
workspaceFolders.of(options.workspaceFolders),
|
||||
viewPlugin,
|
||||
ViewPlugin.define((view) => new KclPlugin()),
|
||||
kclKeymapExt,
|
||||
kclIndentService(),
|
||||
hoverTooltip(
|
||||
@ -104,8 +232,9 @@ export function kclPlugin(options: LanguageServerOptions): Extension {
|
||||
return diagnostics
|
||||
}),
|
||||
folding,
|
||||
autocompleteKeymapExt,
|
||||
autocompletion({
|
||||
defaultKeymap: true,
|
||||
defaultKeymap: false,
|
||||
override: [
|
||||
async (context) => {
|
||||
if (plugin == null) return null
|
||||
|
@ -8,10 +8,19 @@ import {
|
||||
import { LanguageServerClient } from 'editor/plugins/lsp'
|
||||
import { kclPlugin } from '.'
|
||||
import type * as LSP from 'vscode-languageserver-protocol'
|
||||
import { parser as jsParser } from '@lezer/javascript'
|
||||
import { EditorState } from '@uiw/react-codemirror'
|
||||
import KclParser from './parser'
|
||||
import { semanticTokenField } from '../plugin'
|
||||
|
||||
const data = defineLanguageFacet({})
|
||||
const data = defineLanguageFacet({
|
||||
// https://codemirror.net/docs/ref/#commands.CommentTokens
|
||||
commentTokens: {
|
||||
line: '//',
|
||||
block: {
|
||||
open: '/*',
|
||||
close: '*/',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export interface LanguageOptions {
|
||||
workspaceFolders: LSP.WorkspaceFolder[]
|
||||
@ -28,34 +37,24 @@ class KclLanguage extends Language {
|
||||
client: options.client,
|
||||
})
|
||||
|
||||
const parser = new KclParser()
|
||||
|
||||
super(
|
||||
data,
|
||||
// For now let's use the javascript parser.
|
||||
// It works really well and has good syntax highlighting.
|
||||
// We can use our lsp for the rest.
|
||||
jsParser,
|
||||
[
|
||||
plugin,
|
||||
EditorState.languageData.of(() => [
|
||||
{
|
||||
// https://codemirror.net/docs/ref/#commands.CommentTokens
|
||||
commentTokens: {
|
||||
line: '//',
|
||||
block: {
|
||||
open: '/*',
|
||||
close: '*/',
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
parser,
|
||||
[plugin],
|
||||
'kcl'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default function kclLanguage(options: LanguageOptions): LanguageSupport {
|
||||
const lang = new KclLanguage(options)
|
||||
export default class KclLanguageSupport extends LanguageSupport {
|
||||
constructor(options: LanguageOptions) {
|
||||
const lang = new KclLanguage(options)
|
||||
|
||||
return new LanguageSupport(lang)
|
||||
super(lang, [semanticTokenField])
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
// Extends the codemirror Parser for kcl.
|
||||
// This is really just a no-op parser since we use semantic tokens from the LSP
|
||||
// server.
|
||||
|
||||
import {
|
||||
Parser,
|
||||
@ -7,91 +9,27 @@ import {
|
||||
PartialParse,
|
||||
Tree,
|
||||
NodeType,
|
||||
NodeSet,
|
||||
} from '@lezer/common'
|
||||
import { LanguageServerClient } from 'editor/plugins/lsp'
|
||||
import { posToOffset } from 'editor/plugins/lsp/util'
|
||||
import { SemanticToken } from './semantic_tokens'
|
||||
import { DocInput } from '@codemirror/language'
|
||||
import { tags, styleTags } from '@lezer/highlight'
|
||||
|
||||
export default class KclParser extends Parser {
|
||||
private client: LanguageServerClient
|
||||
|
||||
constructor(client: LanguageServerClient) {
|
||||
super()
|
||||
this.client = client
|
||||
}
|
||||
|
||||
createParse(
|
||||
input: Input,
|
||||
fragments: readonly TreeFragment[],
|
||||
ranges: readonly { from: number; to: number }[]
|
||||
): PartialParse {
|
||||
let parse: PartialParse = new Context(this, input, fragments, ranges)
|
||||
let parse: PartialParse = new Context(input)
|
||||
return parse
|
||||
}
|
||||
|
||||
getTokenTypes(): string[] {
|
||||
return this.client.getServerCapabilities().semanticTokensProvider!.legend
|
||||
.tokenTypes
|
||||
}
|
||||
|
||||
getSemanticTokens(): SemanticToken[] {
|
||||
return this.client.getSemanticTokens()
|
||||
}
|
||||
}
|
||||
|
||||
class Context implements PartialParse {
|
||||
private parser: KclParser
|
||||
private input: DocInput
|
||||
private fragments: readonly TreeFragment[]
|
||||
private ranges: readonly { from: number; to: number }[]
|
||||
|
||||
private nodeTypes: { [key: string]: NodeType }
|
||||
stoppedAt: number = 0
|
||||
|
||||
private semanticTokens: SemanticToken[] = []
|
||||
private currentLine: number = 0
|
||||
private currentColumn: number = 0
|
||||
private nodeSet: NodeSet
|
||||
|
||||
constructor(
|
||||
/// The parser configuration used.
|
||||
parser: KclParser,
|
||||
input: Input,
|
||||
fragments: readonly TreeFragment[],
|
||||
ranges: readonly { from: number; to: number }[]
|
||||
) {
|
||||
this.parser = parser
|
||||
constructor(input: Input) {
|
||||
this.input = input as DocInput
|
||||
this.fragments = fragments
|
||||
this.ranges = ranges
|
||||
|
||||
// Iterate over the semantic token types and create a node type for each.
|
||||
this.nodeTypes = {}
|
||||
let nodeArray: NodeType[] = []
|
||||
this.parser.getTokenTypes().forEach((tokenType, index) => {
|
||||
const nodeType = NodeType.define({
|
||||
id: index,
|
||||
name: tokenType,
|
||||
// props: [this.styleTags],
|
||||
})
|
||||
this.nodeTypes[tokenType] = nodeType
|
||||
nodeArray.push(nodeType)
|
||||
})
|
||||
|
||||
this.semanticTokens = this.parser.getSemanticTokens()
|
||||
const styles = styleTags({
|
||||
number: tags.number,
|
||||
variable: tags.variableName,
|
||||
operator: tags.operator,
|
||||
keyword: tags.keyword,
|
||||
string: tags.string,
|
||||
comment: tags.comment,
|
||||
function: tags.function(tags.variableName),
|
||||
})
|
||||
this.nodeSet = new NodeSet(nodeArray).extend(styles)
|
||||
}
|
||||
|
||||
get parsedPos(): number {
|
||||
@ -99,67 +37,8 @@ class Context implements PartialParse {
|
||||
}
|
||||
|
||||
advance(): Tree | null {
|
||||
if (this.semanticTokens.length === 0) {
|
||||
return new Tree(NodeType.none, [], [], 0)
|
||||
}
|
||||
const tree = this.createTree(this.semanticTokens[0], 0)
|
||||
this.stoppedAt = this.input.doc.length
|
||||
return tree
|
||||
}
|
||||
|
||||
createTree(token: SemanticToken, index: number): Tree {
|
||||
const changedLine = token.delta_line !== 0
|
||||
this.currentLine += token.delta_line
|
||||
if (changedLine) {
|
||||
this.currentColumn = 0
|
||||
}
|
||||
this.currentColumn += token.delta_start
|
||||
|
||||
// Let's get our position relative to the start of the file.
|
||||
let currentPosition = posToOffset(this.input.doc, {
|
||||
line: this.currentLine,
|
||||
character: this.currentColumn,
|
||||
})
|
||||
|
||||
const nodeType = this.nodeSet.types[this.nodeTypes[token.token_type].id]
|
||||
|
||||
if (currentPosition === undefined) {
|
||||
// This is bad and weird.
|
||||
return new Tree(nodeType, [], [], token.length)
|
||||
}
|
||||
|
||||
if (index >= this.semanticTokens.length - 1) {
|
||||
// We have no children.
|
||||
return new Tree(nodeType, [], [], token.length)
|
||||
}
|
||||
|
||||
const nextIndex = index + 1
|
||||
const nextToken = this.semanticTokens[nextIndex]
|
||||
const changedLineNext = nextToken.delta_line !== 0
|
||||
const nextLine = this.currentLine + nextToken.delta_line
|
||||
const nextColumn = changedLineNext
|
||||
? nextToken.delta_start
|
||||
: this.currentColumn + nextToken.delta_start
|
||||
const nextPosition = posToOffset(this.input.doc, {
|
||||
line: nextLine,
|
||||
character: nextColumn,
|
||||
})
|
||||
|
||||
if (nextPosition === undefined) {
|
||||
// This is bad and weird.
|
||||
return new Tree(nodeType, [], [], token.length)
|
||||
}
|
||||
|
||||
// Let's get the
|
||||
|
||||
return new Tree(
|
||||
nodeType,
|
||||
[this.createTree(nextToken, nextIndex)],
|
||||
|
||||
// The positions (offsets relative to the start of this tree) of the children.
|
||||
[nextPosition - currentPosition],
|
||||
token.length
|
||||
)
|
||||
return new Tree(NodeType.none, [], [], this.input.doc.length)
|
||||
}
|
||||
|
||||
stopAt(pos: number) {
|
||||
|
@ -1,51 +0,0 @@
|
||||
import type * as LSP from 'vscode-languageserver-protocol'
|
||||
|
||||
export class SemanticToken {
|
||||
delta_line: number
|
||||
delta_start: number
|
||||
length: number
|
||||
token_type: string
|
||||
token_modifiers_bitset: string
|
||||
|
||||
constructor(
|
||||
delta_line = 0,
|
||||
delta_start = 0,
|
||||
length = 0,
|
||||
token_type = '',
|
||||
token_modifiers_bitset = ''
|
||||
) {
|
||||
this.delta_line = delta_line
|
||||
this.delta_start = delta_start
|
||||
this.length = length
|
||||
this.token_type = token_type
|
||||
this.token_modifiers_bitset = token_modifiers_bitset
|
||||
}
|
||||
}
|
||||
|
||||
export async function deserializeTokens(
|
||||
data: number[],
|
||||
semanticTokensProvider?: LSP.SemanticTokensOptions
|
||||
): Promise<SemanticToken[]> {
|
||||
if (!semanticTokensProvider) {
|
||||
return []
|
||||
}
|
||||
// Check if data length is divisible by 5
|
||||
if (data.length % 5 !== 0) {
|
||||
return Promise.reject(new Error('Length is not divisible by 5'))
|
||||
}
|
||||
|
||||
const tokens = []
|
||||
for (let i = 0; i < data.length; i += 5) {
|
||||
tokens.push(
|
||||
new SemanticToken(
|
||||
data[i],
|
||||
data[i + 1],
|
||||
data[i + 2],
|
||||
semanticTokensProvider.legend.tokenTypes[data[i + 3]],
|
||||
semanticTokensProvider.legend.tokenModifiers[data[i + 4]]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
@ -1,7 +1,24 @@
|
||||
import { completeFromList, snippetCompletion } from '@codemirror/autocomplete'
|
||||
import { setDiagnostics } from '@codemirror/lint'
|
||||
import { Facet } from '@codemirror/state'
|
||||
import { EditorView, Tooltip } from '@codemirror/view'
|
||||
import {
|
||||
completeFromList,
|
||||
hasNextSnippetField,
|
||||
pickedCompletion,
|
||||
snippetCompletion,
|
||||
} from '@codemirror/autocomplete'
|
||||
import {
|
||||
Facet,
|
||||
StateEffect,
|
||||
StateField,
|
||||
Extension,
|
||||
Annotation,
|
||||
Transaction,
|
||||
} from '@codemirror/state'
|
||||
import {
|
||||
EditorView,
|
||||
Tooltip,
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
} from '@codemirror/view'
|
||||
import { URI } from 'vscode-uri'
|
||||
import {
|
||||
DiagnosticSeverity,
|
||||
CompletionItemKind,
|
||||
@ -21,49 +38,247 @@ import { LanguageServerClient } from 'editor/plugins/lsp'
|
||||
import { Marked } from '@ts-stack/markdown'
|
||||
import { posToOffset } from 'editor/plugins/lsp/util'
|
||||
import { Program, ProgramMemory } from 'lang/wasm'
|
||||
import { codeManager, editorManager, kclManager } from 'lib/singletons'
|
||||
import { codeManager, editorManager } 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'
|
||||
import { copilotPluginEvent } from './copilot'
|
||||
import { codeManagerUpdateEvent } from 'lang/codeManager'
|
||||
import {
|
||||
modelingMachineEvent,
|
||||
updateOutsideEditorEvent,
|
||||
setDiagnosticsEvent,
|
||||
} from 'editor/manager'
|
||||
import { SemanticToken, getTag } from 'editor/plugins/lsp/semantic_token'
|
||||
import { highlightingFor } from '@codemirror/language'
|
||||
|
||||
const useLast = (values: readonly any[]) => values.reduce((_, v) => v, '')
|
||||
export const documentUri = Facet.define<string, string>({ combine: useLast })
|
||||
export const docPathFacet = 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 })
|
||||
|
||||
enum LspAnnotation {
|
||||
SemanticTokens = 'semantic-tokens',
|
||||
}
|
||||
|
||||
const lspEvent = Annotation.define<LspAnnotation>()
|
||||
export const lspSemanticTokensEvent = lspEvent.of(LspAnnotation.SemanticTokens)
|
||||
|
||||
const CompletionItemKindMap = Object.fromEntries(
|
||||
Object.entries(CompletionItemKind).map(([key, value]) => [value, key])
|
||||
) as Record<CompletionItemKind, string>
|
||||
|
||||
const changesDelay = 600
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const updateDelay = 100
|
||||
|
||||
const addToken = StateEffect.define<SemanticToken>({
|
||||
map: (token: SemanticToken, change) => ({
|
||||
...token,
|
||||
from: change.mapPos(token.from),
|
||||
to: change.mapPos(token.to),
|
||||
}),
|
||||
})
|
||||
|
||||
export const semanticTokenField = StateField.define<DecorationSet>({
|
||||
create() {
|
||||
return Decoration.none
|
||||
},
|
||||
update(highlights, tr) {
|
||||
// Nothing can come before this line, this is very important!
|
||||
// It makes sure the highlights are updated correctly for the changes.
|
||||
highlights = highlights.map(tr.changes)
|
||||
|
||||
const isSemanticTokensEvent = tr.annotation(lspSemanticTokensEvent.type)
|
||||
if (!isSemanticTokensEvent) {
|
||||
return highlights
|
||||
}
|
||||
|
||||
// Check if any of the changes are addToken
|
||||
const hasAddToken = tr.effects.some((e) => e.is(addToken))
|
||||
if (hasAddToken) {
|
||||
highlights = highlights.update({
|
||||
filter: (from, to) => false,
|
||||
})
|
||||
}
|
||||
|
||||
for (const e of tr.effects)
|
||||
if (e.is(addToken)) {
|
||||
const tag = getTag(e.value)
|
||||
const className = tag
|
||||
? highlightingFor(tr.startState, [tag])
|
||||
: undefined
|
||||
|
||||
if (e.value.from < e.value.to && tag) {
|
||||
if (className) {
|
||||
highlights = highlights.update({
|
||||
add: [
|
||||
Decoration.mark({ class: className }).range(
|
||||
e.value.from,
|
||||
e.value.to
|
||||
),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return highlights
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f),
|
||||
})
|
||||
|
||||
export enum TransactionAnnotation {
|
||||
Diagnostics = 'diagnostics',
|
||||
Remote = 'remote',
|
||||
UserSelect = 'user.select',
|
||||
UserInput = 'user.input',
|
||||
UserMove = 'user.move',
|
||||
UserDelete = 'user.delete',
|
||||
UserUndo = 'user.undo',
|
||||
UserRedo = 'user.redo',
|
||||
|
||||
Copoilot = 'copilot',
|
||||
OutsideEditor = 'outsideEditor',
|
||||
CodeManager = 'codeManager',
|
||||
ModelingMachine = 'modelingMachineEvent',
|
||||
LspSemanticTokens = 'lspSemanticTokensEvent',
|
||||
|
||||
PickedCompletion = 'pickedCompletion',
|
||||
}
|
||||
|
||||
export interface TransactionInfo {
|
||||
annotations: TransactionAnnotation[]
|
||||
time: number | null
|
||||
docChanged: boolean
|
||||
addToHistory: boolean
|
||||
inSnippet: boolean
|
||||
}
|
||||
|
||||
export const updateInfo = (update: ViewUpdate): TransactionInfo[] => {
|
||||
let transactionInfos: TransactionInfo[] = []
|
||||
|
||||
for (const tr of update.transactions) {
|
||||
let annotations: TransactionAnnotation[] = []
|
||||
|
||||
if (tr.isUserEvent('select')) {
|
||||
annotations.push(TransactionAnnotation.UserSelect)
|
||||
}
|
||||
|
||||
if (tr.isUserEvent('input')) {
|
||||
annotations.push(TransactionAnnotation.UserInput)
|
||||
}
|
||||
if (tr.isUserEvent('delete')) {
|
||||
annotations.push(TransactionAnnotation.UserDelete)
|
||||
}
|
||||
if (tr.isUserEvent('undo')) {
|
||||
annotations.push(TransactionAnnotation.UserUndo)
|
||||
}
|
||||
if (tr.isUserEvent('redo')) {
|
||||
annotations.push(TransactionAnnotation.UserRedo)
|
||||
}
|
||||
if (tr.isUserEvent('move')) {
|
||||
annotations.push(TransactionAnnotation.UserMove)
|
||||
}
|
||||
|
||||
if (tr.annotation(pickedCompletion) !== undefined) {
|
||||
annotations.push(TransactionAnnotation.PickedCompletion)
|
||||
}
|
||||
|
||||
if (tr.annotation(copilotPluginEvent.type) !== undefined) {
|
||||
annotations.push(TransactionAnnotation.Copoilot)
|
||||
}
|
||||
|
||||
if (tr.annotation(updateOutsideEditorEvent.type) !== undefined) {
|
||||
annotations.push(TransactionAnnotation.OutsideEditor)
|
||||
}
|
||||
|
||||
if (tr.annotation(codeManagerUpdateEvent.type) !== undefined) {
|
||||
annotations.push(TransactionAnnotation.CodeManager)
|
||||
}
|
||||
|
||||
if (tr.annotation(modelingMachineEvent.type) !== undefined) {
|
||||
annotations.push(TransactionAnnotation.ModelingMachine)
|
||||
}
|
||||
|
||||
if (tr.annotation(lspSemanticTokensEvent.type) !== undefined) {
|
||||
annotations.push(TransactionAnnotation.LspSemanticTokens)
|
||||
}
|
||||
|
||||
if (tr.annotation(setDiagnosticsEvent.type) !== undefined) {
|
||||
annotations.push(TransactionAnnotation.Diagnostics)
|
||||
}
|
||||
|
||||
if (tr.annotation(Transaction.remote) !== undefined) {
|
||||
annotations.push(TransactionAnnotation.Remote)
|
||||
}
|
||||
|
||||
transactionInfos.push({
|
||||
annotations,
|
||||
time: tr.annotation(Transaction.time) || null,
|
||||
docChanged: tr.docChanged,
|
||||
addToHistory: tr.annotation(Transaction.addToHistory) || false,
|
||||
inSnippet: hasNextSnippetField(update.state),
|
||||
})
|
||||
}
|
||||
|
||||
return transactionInfos
|
||||
}
|
||||
|
||||
export interface RelevantUpdate {
|
||||
overall: boolean
|
||||
userSelect: boolean
|
||||
time: number | null
|
||||
}
|
||||
|
||||
export const relevantUpdate = (update: ViewUpdate): RelevantUpdate => {
|
||||
const infos = updateInfo(update)
|
||||
// Make sure we are not in a snippet
|
||||
if (infos.some((info) => info.inSnippet)) {
|
||||
return {
|
||||
overall: false,
|
||||
userSelect: false,
|
||||
time: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
overall: infos.some(
|
||||
(info) =>
|
||||
info.docChanged ||
|
||||
info.annotations.includes(TransactionAnnotation.UserInput) ||
|
||||
info.annotations.includes(TransactionAnnotation.UserDelete) ||
|
||||
info.annotations.includes(TransactionAnnotation.UserUndo) ||
|
||||
info.annotations.includes(TransactionAnnotation.UserRedo) ||
|
||||
info.annotations.includes(TransactionAnnotation.UserMove)
|
||||
),
|
||||
userSelect: infos.some((info) =>
|
||||
info.annotations.includes(TransactionAnnotation.UserSelect)
|
||||
),
|
||||
time: infos.length ? infos[0].time : null,
|
||||
}
|
||||
}
|
||||
|
||||
export class LanguageServerPlugin implements PluginValue {
|
||||
public client: LanguageServerClient
|
||||
public documentUri: string
|
||||
public languageId: string
|
||||
public workspaceFolders: LSP.WorkspaceFolder[]
|
||||
private documentVersion: number
|
||||
private foldingRanges: LSP.FoldingRange[] | null = null
|
||||
private viewUpdate: ViewUpdate | null = null
|
||||
|
||||
private previousSemanticTokens: SemanticToken[] = []
|
||||
|
||||
private _defferer = deferExecution((code: string) => {
|
||||
try {
|
||||
// Update the state (not the editor) with the new code.
|
||||
this.client.textDocumentDidChange({
|
||||
textDocument: {
|
||||
uri: this.documentUri,
|
||||
uri: this.getDocUri(),
|
||||
version: this.documentVersion++,
|
||||
},
|
||||
contentChanges: [{ text: code }],
|
||||
})
|
||||
|
||||
if (this.viewUpdate) {
|
||||
editorManager.handleOnViewUpdate(this.viewUpdate)
|
||||
}
|
||||
this.requestSemanticTokens(this.view)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
@ -75,41 +290,43 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
private allowHTMLContent: boolean
|
||||
) {
|
||||
this.client = client
|
||||
this.documentUri = this.view.state.facet(documentUri)
|
||||
this.languageId = this.view.state.facet(languageId)
|
||||
this.workspaceFolders = this.view.state.facet(workspaceFolders)
|
||||
this.documentVersion = 0
|
||||
|
||||
this.client.attachPlugin(this)
|
||||
|
||||
this.initialize({
|
||||
documentText: this.view.state.doc.toString(),
|
||||
documentText: this.getDocText(),
|
||||
})
|
||||
}
|
||||
|
||||
update(viewUpdate: ViewUpdate) {
|
||||
this.viewUpdate = viewUpdate
|
||||
if (!viewUpdate.docChanged) {
|
||||
// debounce the view update.
|
||||
// otherwise it is laggy for typing.
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
private getDocPath(view = this.view) {
|
||||
return view.state.facet(docPathFacet)
|
||||
}
|
||||
private getDocText(view = this.view) {
|
||||
return view.state.doc.toString()
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
editorManager.handleOnViewUpdate(viewUpdate)
|
||||
}, updateDelay)
|
||||
private getDocUri(view = this.view) {
|
||||
return URI.file(this.getDocPath(view)).toString()
|
||||
}
|
||||
|
||||
private getLanguageId(view = this.view) {
|
||||
return view.state.facet(languageId)
|
||||
}
|
||||
|
||||
update(viewUpdate: ViewUpdate) {
|
||||
const isRelevant = relevantUpdate(viewUpdate)
|
||||
if (!isRelevant.overall) {
|
||||
return
|
||||
}
|
||||
|
||||
const newCode = this.view.state.doc.toString()
|
||||
|
||||
codeManager.code = newCode
|
||||
codeManager.writeToFile()
|
||||
kclManager.executeCode()
|
||||
// If the doc didn't change we can return early.
|
||||
if (!viewUpdate.docChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
this.sendChange({
|
||||
documentText: newCode,
|
||||
documentText: viewUpdate.state.doc.toString(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -121,14 +338,17 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
if (this.client.initializePromise) {
|
||||
await this.client.initializePromise
|
||||
}
|
||||
|
||||
this.client.textDocumentDidOpen({
|
||||
textDocument: {
|
||||
uri: this.documentUri,
|
||||
languageId: this.languageId,
|
||||
uri: this.getDocUri(),
|
||||
languageId: this.getLanguageId(),
|
||||
text: documentText,
|
||||
version: this.documentVersion,
|
||||
},
|
||||
})
|
||||
|
||||
this.requestSemanticTokens(this.view)
|
||||
}
|
||||
|
||||
async sendChange({ documentText }: { documentText: string }) {
|
||||
@ -138,7 +358,7 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
}
|
||||
|
||||
requestDiagnostics(view: EditorView) {
|
||||
this.sendChange({ documentText: view.state.doc.toString() })
|
||||
this.sendChange({ documentText: this.getDocText() })
|
||||
}
|
||||
|
||||
async requestHoverTooltip(
|
||||
@ -151,9 +371,9 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
)
|
||||
return null
|
||||
|
||||
this.sendChange({ documentText: view.state.doc.toString() })
|
||||
this.sendChange({ documentText: this.getDocText() })
|
||||
const result = await this.client.textDocumentHover({
|
||||
textDocument: { uri: this.documentUri },
|
||||
textDocument: { uri: this.getDocUri() },
|
||||
position: { line, character },
|
||||
})
|
||||
if (!result) return null
|
||||
@ -181,7 +401,7 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
)
|
||||
return null
|
||||
const result = await this.client.textDocumentFoldingRange({
|
||||
textDocument: { uri: this.documentUri },
|
||||
textDocument: { uri: this.getDocUri() },
|
||||
})
|
||||
|
||||
return result || null
|
||||
@ -228,9 +448,9 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
|
||||
return await this.client.updateUnits({
|
||||
textDocument: {
|
||||
uri: this.documentUri,
|
||||
uri: this.getDocUri(),
|
||||
},
|
||||
text: this.view.state.doc.toString(),
|
||||
text: this.getDocText(),
|
||||
units,
|
||||
})
|
||||
}
|
||||
@ -254,7 +474,6 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
})
|
||||
}
|
||||
}
|
||||
console.log('[lsp] kcl: updated canExecute', canExecute, response)
|
||||
return response
|
||||
}
|
||||
|
||||
@ -267,14 +486,14 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
|
||||
this.client.textDocumentDidChange({
|
||||
textDocument: {
|
||||
uri: this.documentUri,
|
||||
uri: this.getDocUri(),
|
||||
version: this.documentVersion++,
|
||||
},
|
||||
contentChanges: [{ text: this.view.state.doc.toString() }],
|
||||
contentChanges: [{ text: this.getDocText() }],
|
||||
})
|
||||
|
||||
const result = await this.client.textDocumentFormatting({
|
||||
textDocument: { uri: this.documentUri },
|
||||
textDocument: { uri: this.getDocUri() },
|
||||
options: {
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
@ -285,16 +504,8 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
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,
|
||||
},
|
||||
],
|
||||
})
|
||||
const { newText } = result[i]
|
||||
codeManager.updateCodeStateEditor(newText)
|
||||
}
|
||||
}
|
||||
|
||||
@ -320,7 +531,7 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
})
|
||||
|
||||
const result = await this.client.textDocumentCompletion({
|
||||
textDocument: { uri: this.documentUri },
|
||||
textDocument: { uri: this.getDocUri() },
|
||||
position: { line, character },
|
||||
context: {
|
||||
triggerKind,
|
||||
@ -379,16 +590,107 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
return completeFromList(options)(context)
|
||||
}
|
||||
|
||||
parseSemanticTokens(view: EditorView, data: number[]) {
|
||||
// decode the lsp semantic token types
|
||||
const tokens = []
|
||||
for (let i = 0; i < data.length; i += 5) {
|
||||
tokens.push({
|
||||
deltaLine: data[i],
|
||||
startChar: data[i + 1],
|
||||
length: data[i + 2],
|
||||
tokenType: data[i + 3],
|
||||
modifiers: data[i + 4],
|
||||
})
|
||||
}
|
||||
|
||||
// convert the tokens into an array of {to, from, type} objects
|
||||
const tokenTypes =
|
||||
this.client.getServerCapabilities().semanticTokensProvider!.legend
|
||||
.tokenTypes
|
||||
const tokenModifiers =
|
||||
this.client.getServerCapabilities().semanticTokensProvider!.legend
|
||||
.tokenModifiers
|
||||
const tokenRanges: any = []
|
||||
let curLine = 0
|
||||
let prevStart = 0
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i]
|
||||
const tokenType = tokenTypes[token.tokenType]
|
||||
// get a list of modifiers
|
||||
const tokenModifier = []
|
||||
for (let j = 0; j < tokenModifiers.length; j++) {
|
||||
if (token.modifiers & (1 << j)) {
|
||||
tokenModifier.push(tokenModifiers[j])
|
||||
}
|
||||
}
|
||||
|
||||
if (token.deltaLine !== 0) prevStart = 0
|
||||
|
||||
const tokenRange = {
|
||||
from: posToOffset(view.state.doc, {
|
||||
line: curLine + token.deltaLine,
|
||||
character: prevStart + token.startChar,
|
||||
})!,
|
||||
to: posToOffset(view.state.doc, {
|
||||
line: curLine + token.deltaLine,
|
||||
character: prevStart + token.startChar + token.length,
|
||||
})!,
|
||||
type: tokenType,
|
||||
modifiers: tokenModifier,
|
||||
}
|
||||
tokenRanges.push(tokenRange)
|
||||
|
||||
curLine += token.deltaLine
|
||||
prevStart += token.startChar
|
||||
}
|
||||
|
||||
// sort by from
|
||||
tokenRanges.sort((a: any, b: any) => a.from - b.from)
|
||||
return tokenRanges
|
||||
}
|
||||
|
||||
async requestSemanticTokens(view: EditorView) {
|
||||
if (
|
||||
!this.client.ready ||
|
||||
!this.client.getServerCapabilities().semanticTokensProvider
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = await this.client.textDocumentSemanticTokensFull({
|
||||
textDocument: { uri: this.getDocUri() },
|
||||
})
|
||||
if (!result) return null
|
||||
|
||||
const { data } = result
|
||||
this.previousSemanticTokens = this.parseSemanticTokens(view, data)
|
||||
|
||||
const effects: StateEffect<SemanticToken | Extension>[] =
|
||||
this.previousSemanticTokens.map((tokenRange: any) =>
|
||||
addToken.of(tokenRange)
|
||||
)
|
||||
|
||||
view.dispatch({
|
||||
effects,
|
||||
|
||||
annotations: [lspSemanticTokensEvent, Transaction.addToHistory.of(false)],
|
||||
})
|
||||
}
|
||||
|
||||
async processNotification(notification: LSP.NotificationMessage) {
|
||||
try {
|
||||
switch (notification.method) {
|
||||
case 'textDocument/publishDiagnostics':
|
||||
if (notification === undefined) break
|
||||
if (notification.params === undefined) break
|
||||
if (!notification.params) break
|
||||
const params = notification.params as PublishDiagnosticsParams
|
||||
if (!params) break
|
||||
console.log(
|
||||
'[lsp] [window/publishDiagnostics]',
|
||||
this.client.getName(),
|
||||
notification.params
|
||||
params
|
||||
)
|
||||
const params = notification.params as PublishDiagnosticsParams
|
||||
// this is sometimes slower than our actual typing.
|
||||
this.processDiagnostics(params)
|
||||
break
|
||||
@ -420,7 +722,6 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
// 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
|
||||
}
|
||||
} catch (error) {
|
||||
@ -429,7 +730,7 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
}
|
||||
|
||||
processDiagnostics(params: PublishDiagnosticsParams) {
|
||||
if (params.uri !== this.documentUri) return
|
||||
if (params.uri !== this.getDocUri()) return
|
||||
|
||||
const diagnostics = params.diagnostics
|
||||
.map(({ range, message, severity }) => ({
|
||||
@ -459,7 +760,7 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
return 0
|
||||
})
|
||||
|
||||
this.view.dispatch(setDiagnostics(this.view.state, diagnostics))
|
||||
editorManager.addDiagnostics(diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
|
112
src/editor/plugins/lsp/semantic_token.ts
Normal file
112
src/editor/plugins/lsp/semantic_token.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { Tag, tags } from '@lezer/highlight'
|
||||
|
||||
export interface SemanticToken {
|
||||
from: number
|
||||
to: number
|
||||
type: string
|
||||
modifiers: string[]
|
||||
}
|
||||
|
||||
export function getTag(semanticToken: SemanticToken): Tag | null {
|
||||
let tokenType = convertSemanticTokenTypeToCodeMirrorTag(semanticToken.type)
|
||||
|
||||
if (
|
||||
semanticToken.modifiers === undefined ||
|
||||
semanticToken.modifiers === null ||
|
||||
semanticToken.modifiers.length === 0
|
||||
) {
|
||||
return tokenType
|
||||
}
|
||||
|
||||
for (let modifier of semanticToken.modifiers) {
|
||||
tokenType = convertSemanticTokenToCodeMirrorTag(
|
||||
'',
|
||||
modifier,
|
||||
tokenType || undefined
|
||||
)
|
||||
}
|
||||
|
||||
return tokenType
|
||||
}
|
||||
|
||||
export function getTagName(semanticToken: SemanticToken): string {
|
||||
let tokenType = semanticToken.type
|
||||
|
||||
if (
|
||||
semanticToken.modifiers === undefined ||
|
||||
semanticToken.modifiers === null ||
|
||||
semanticToken.modifiers.length === 0
|
||||
) {
|
||||
return tokenType
|
||||
}
|
||||
|
||||
for (let modifier of semanticToken.modifiers) {
|
||||
tokenType = `${tokenType}.${modifier}`
|
||||
}
|
||||
|
||||
return tokenType
|
||||
}
|
||||
|
||||
function convertSemanticTokenTypeToCodeMirrorTag(
|
||||
tokenType: string
|
||||
): Tag | null {
|
||||
switch (tokenType) {
|
||||
case 'keyword':
|
||||
return tags.keyword
|
||||
case 'variable':
|
||||
return tags.variableName
|
||||
case 'string':
|
||||
return tags.string
|
||||
case 'number':
|
||||
return tags.number
|
||||
case 'comment':
|
||||
return tags.comment
|
||||
case 'operator':
|
||||
return tags.operator
|
||||
case 'function':
|
||||
return tags.function(tags.name)
|
||||
case 'type':
|
||||
return tags.typeName
|
||||
case 'property':
|
||||
return tags.propertyName
|
||||
case 'parameter':
|
||||
return tags.local(tags.name)
|
||||
default:
|
||||
console.error('Unknown token type:', tokenType)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function convertSemanticTokenToCodeMirrorTag(
|
||||
tokenType: string,
|
||||
tokenModifier: string,
|
||||
givenTag?: Tag
|
||||
): Tag | null {
|
||||
let tag = givenTag
|
||||
? givenTag
|
||||
: convertSemanticTokenTypeToCodeMirrorTag(tokenType)
|
||||
|
||||
if (!tag) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (tokenModifier) {
|
||||
switch (tokenModifier) {
|
||||
case 'definition':
|
||||
return tags.definition(tag)
|
||||
case 'declaration':
|
||||
return tags.definition(tag)
|
||||
case 'readonly':
|
||||
return tags.constant(tag)
|
||||
case 'static':
|
||||
return tags.constant(tag)
|
||||
case 'defaultLibrary':
|
||||
return tags.standard(tag)
|
||||
default:
|
||||
console.error('Unknown token modifier:', tokenModifier)
|
||||
return tag
|
||||
}
|
||||
}
|
||||
|
||||
return tag
|
||||
}
|
@ -6,10 +6,13 @@ import { isTauri } from 'lib/isTauri'
|
||||
import { writeTextFile } from '@tauri-apps/plugin-fs'
|
||||
import toast from 'react-hot-toast'
|
||||
import { editorManager } from 'lib/singletons'
|
||||
import { KeyBinding } from '@uiw/react-codemirror'
|
||||
import { Annotation, KeyBinding, Transaction } from '@uiw/react-codemirror'
|
||||
|
||||
const PERSIST_CODE_TOKEN = 'persistCode'
|
||||
|
||||
const codeManagerUpdateAnnotation = Annotation.define<null>()
|
||||
export const codeManagerUpdateEvent = codeManagerUpdateAnnotation.of(null)
|
||||
|
||||
export default class CodeManager {
|
||||
private _code: string = bracket
|
||||
#updateState: (arg: string) => void = () => {}
|
||||
@ -90,6 +93,10 @@ export default class CodeManager {
|
||||
to: editorManager.editorView.state.doc.length,
|
||||
insert: code,
|
||||
},
|
||||
annotations: [
|
||||
codeManagerUpdateEvent,
|
||||
Transaction.addToHistory.of(true),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -19,7 +19,6 @@ import {
|
||||
createPipeSubstitution,
|
||||
} from './modifyAst'
|
||||
import { err } from 'lib/trap'
|
||||
import { warn } from 'node:console'
|
||||
|
||||
beforeAll(async () => {
|
||||
await initPromise
|
||||
|
@ -282,8 +282,10 @@ function moreNodePathFromSourceRange(
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
if (_node.type === 'PipeSubstitution' && isInRange) return path
|
||||
console.error('not implemented: ' + node.type)
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
|
6
src/wasm-lib/Cargo.lock
generated
6
src/wasm-lib/Cargo.lock
generated
@ -712,7 +712,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "derive-docs"
|
||||
version = "0.1.19"
|
||||
version = "0.1.20"
|
||||
dependencies = [
|
||||
"Inflector",
|
||||
"anyhow",
|
||||
@ -1385,7 +1385,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kcl-lib"
|
||||
version = "0.1.68"
|
||||
version = "0.1.69"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"approx",
|
||||
@ -1453,7 +1453,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kcl-test-server"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hyper",
|
||||
|
@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "derive-docs"
|
||||
description = "A tool for generating documentation from Rust derive macros"
|
||||
version = "0.1.19"
|
||||
version = "0.1.20"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/KittyCAD/modeling-app"
|
||||
|
@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "kcl-test-server"
|
||||
description = "A test server for KCL"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "kcl-lib"
|
||||
description = "KittyCAD Language implementation and tools"
|
||||
version = "0.1.68"
|
||||
version = "0.1.69"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/KittyCAD/modeling-app"
|
||||
@ -19,7 +19,7 @@ chrono = "0.4.38"
|
||||
clap = { version = "4.5.7", default-features = false, optional = true }
|
||||
dashmap = "6.0.1"
|
||||
databake = { version = "0.1.8", features = ["derive"] }
|
||||
derive-docs = { version = "0.1.19", path = "../derive-docs" }
|
||||
derive-docs = { version = "0.1.20", path = "../derive-docs" }
|
||||
form_urlencoded = "1.2.1"
|
||||
futures = { version = "0.3.30" }
|
||||
git_rev = "0.1.0"
|
||||
|
@ -70,11 +70,8 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
println!("on_change after check: {:?}", params);
|
||||
|
||||
self.insert_code_map(params.uri.to_string(), params.text.as_bytes().to_vec())
|
||||
.await;
|
||||
println!("on_change after insert: {:?}", params);
|
||||
self.inner_on_change(params, false).await;
|
||||
}
|
||||
|
||||
|
@ -39,11 +39,10 @@ use tower_lsp::{
|
||||
Client, LanguageServer,
|
||||
};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::lint::checks;
|
||||
use crate::{
|
||||
ast::types::{Value, VariableKind},
|
||||
executor::SourceRange,
|
||||
lint::checks,
|
||||
lsp::{backend::Backend as _, util::IntoDiagnostic},
|
||||
parser::PIPE_OPERATOR,
|
||||
token::TokenType,
|
||||
@ -269,15 +268,12 @@ impl crate::lsp::backend::Backend for Backend {
|
||||
// Update our semantic tokens.
|
||||
self.update_semantic_tokens(&tokens, ¶ms).await;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let discovered_findings = ast
|
||||
.lint(checks::lint_variables)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
self.add_to_diagnostics(¶ms, &discovered_findings, false).await;
|
||||
}
|
||||
let discovered_findings = ast
|
||||
.lint(checks::lint_variables)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
self.add_to_diagnostics(¶ms, &discovered_findings, false).await;
|
||||
}
|
||||
|
||||
// Send the notification to the client that the ast was updated.
|
||||
@ -533,9 +529,9 @@ impl Backend {
|
||||
diagnostics: &[DiagT],
|
||||
clear_all_before_add: bool,
|
||||
) {
|
||||
self.client
|
||||
.log_message(MessageType::INFO, format!("adding {:?} to diag", diagnostics))
|
||||
.await;
|
||||
if diagnostics.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if clear_all_before_add {
|
||||
self.clear_diagnostics_map(¶ms.uri, None).await;
|
||||
@ -645,20 +641,6 @@ impl Backend {
|
||||
modifier
|
||||
}
|
||||
|
||||
async fn completions_get_variables_from_ast(&self, file_name: &str) -> Vec<CompletionItem> {
|
||||
let ast = match self.ast_map.get(file_name) {
|
||||
Some(ast) => ast,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
// Get the completion items.
|
||||
match ast.completion_items() {
|
||||
Ok(items) => items,
|
||||
// TODO: don't ignore an error here.
|
||||
Err(_err) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_zip(&self) -> Result<Vec<u8>> {
|
||||
// Collect all the file data we know.
|
||||
let mut buf = vec![];
|
||||
@ -1055,9 +1037,34 @@ impl LanguageServer for Backend {
|
||||
|
||||
completions.extend(self.stdlib_completions.values().cloned());
|
||||
|
||||
let variables = self
|
||||
.completions_get_variables_from_ast(params.text_document_position.text_document.uri.as_ref())
|
||||
.await;
|
||||
// Add more to the completions if we have more.
|
||||
let Some(ast) = self
|
||||
.ast_map
|
||||
.get(params.text_document_position.text_document.uri.as_ref())
|
||||
else {
|
||||
return Ok(Some(CompletionResponse::Array(completions)));
|
||||
};
|
||||
|
||||
let Some(current_code) = self
|
||||
.code_map
|
||||
.get(params.text_document_position.text_document.uri.as_ref())
|
||||
else {
|
||||
return Ok(Some(CompletionResponse::Array(completions)));
|
||||
};
|
||||
let Ok(current_code) = std::str::from_utf8(¤t_code) else {
|
||||
return Ok(Some(CompletionResponse::Array(completions)));
|
||||
};
|
||||
|
||||
let position = position_to_char_index(params.text_document_position.position, current_code);
|
||||
if ast.get_non_code_meta_for_position(position).is_some() {
|
||||
// If we are in a code comment we don't want to show completions.
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Get the completion items forem the ast.
|
||||
let Ok(variables) = ast.completion_items() else {
|
||||
return Ok(Some(CompletionResponse::Array(completions)));
|
||||
};
|
||||
|
||||
// Get our variables from our AST to include in our completions.
|
||||
completions.extend(variables);
|
||||
|
@ -660,6 +660,41 @@ st"#
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_kcl_lsp_completions_empty_in_comment() {
|
||||
let server = kcl_lsp_server(false).await.unwrap();
|
||||
|
||||
// Send open file.
|
||||
server
|
||||
.did_open(tower_lsp::lsp_types::DidOpenTextDocumentParams {
|
||||
text_document: tower_lsp::lsp_types::TextDocumentItem {
|
||||
uri: "file:///test.kcl".try_into().unwrap(),
|
||||
language_id: "kcl".to_string(),
|
||||
version: 1,
|
||||
text: r#"const thing= 1 // st"#.to_string(),
|
||||
},
|
||||
})
|
||||
.await;
|
||||
|
||||
// Send completion request.
|
||||
let completions = server
|
||||
.completion(tower_lsp::lsp_types::CompletionParams {
|
||||
text_document_position: tower_lsp::lsp_types::TextDocumentPositionParams {
|
||||
text_document: tower_lsp::lsp_types::TextDocumentIdentifier {
|
||||
uri: "file:///test.kcl".try_into().unwrap(),
|
||||
},
|
||||
position: tower_lsp::lsp_types::Position { line: 0, character: 19 },
|
||||
},
|
||||
context: None,
|
||||
partial_result_params: Default::default(),
|
||||
work_done_progress_params: Default::default(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(completions.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_kcl_lsp_completions_tags() {
|
||||
let server = kcl_lsp_server(false).await.unwrap();
|
||||
|
@ -93,6 +93,8 @@ impl TryFrom<TokenType> for SemanticTokenType {
|
||||
|
||||
impl TokenType {
|
||||
// This is for the lsp server.
|
||||
// Don't call this function directly in the code use a lazy_static instead
|
||||
// like we do in the lsp server.
|
||||
pub fn all_semantic_token_types() -> Result<Vec<SemanticTokenType>> {
|
||||
let mut settings = schemars::gen::SchemaSettings::openapi3();
|
||||
settings.inline_subschemas = true;
|
||||
|
Reference in New Issue
Block a user