Ghost text (#888)
* copilot Signed-off-by: Jess Frazelle <github@jessfraz.com> * updates Signed-off-by: Jess Frazelle <github@jessfraz.com> * refactor layout for copilot lsp Signed-off-by: Jess Frazelle <github@jessfraz.com> * start of server Signed-off-by: Jess Frazelle <github@jessfraz.com> * updates Signed-off-by: Jess Frazelle <github@jessfraz.com> * more clippy Signed-off-by: Jess Frazelle <github@jessfraz.com> * cleanup code Signed-off-by: Jess Frazelle <github@jessfraz.com> * fix Signed-off-by: Jess Frazelle <github@jessfraz.com> * compile wasm Signed-off-by: Jess Frazelle <github@jessfraz.com> * make work w wasm Signed-off-by: Jess Frazelle <github@jessfraz.com> * clippy Signed-off-by: Jess Frazelle <github@jessfraz.com> * cleanup Signed-off-by: Jess Frazelle <github@jessfraz.com> * cleanup Signed-off-by: Jess Frazelle <github@jessfraz.com> * cleanup unwraps 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> * tests Signed-off-by: Jess Frazelle <github@jessfraz.com> * point to correct things 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> * updates Signed-off-by: Jess Frazelle <github@jessfraz.com> * cleanup Signed-off-by: Jess Frazelle <github@jessfraz.com> * updaes 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> * shared backend features Signed-off-by: Jess Frazelle <github@jessfraz.com> * framework for workspace 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> * updates Signed-off-by: Jess Frazelle <github@jessfraz.com> * cleanup lints Signed-off-by: Jess Frazelle <github@jessfraz.com> * fmt Signed-off-by: Jess Frazelle <github@jessfraz.com> --------- Signed-off-by: Jess Frazelle <github@jessfraz.com>
This commit is contained in:
@ -1,3 +1,3 @@
|
||||
[codespell]
|
||||
ignore-words-list: crate,everytime,inout,co-ordinate,ot
|
||||
ignore-words-list: crate,everytime,inout,co-ordinate,ot,nwo
|
||||
skip: **/target,node_modules,build,**/Cargo.lock
|
||||
|
@ -7,8 +7,8 @@ use std::io::Read;
|
||||
|
||||
use anyhow::Result;
|
||||
use oauth2::TokenResponse;
|
||||
use tauri::{InvokeError, Manager};
|
||||
use std::process::Command;
|
||||
use tauri::{InvokeError, Manager};
|
||||
const DEFAULT_HOST: &str = "https://api.kittycad.io";
|
||||
|
||||
/// This command returns the a json string parse from a toml file at the path.
|
||||
@ -158,10 +158,7 @@ fn show_in_folder(path: String) {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new("open")
|
||||
.args(["-R", &path])
|
||||
.spawn()
|
||||
.unwrap();
|
||||
Command::new("open").args(["-R", &path]).spawn().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -27,6 +27,7 @@ import { engineCommandManager } from '../lang/std/engineConnection'
|
||||
import { kclManager, useKclContext } from 'lang/KclSingleton'
|
||||
import { ModelingMachineEvent } from 'machines/modelingMachine'
|
||||
import { sceneInfra } from 'clientSideScene/sceneInfra'
|
||||
import { copilotBundle } from 'editor/copilot'
|
||||
|
||||
export const editorShortcutMeta = {
|
||||
formatCode: {
|
||||
@ -46,15 +47,19 @@ export const TextEditor = ({
|
||||
}) => {
|
||||
const {
|
||||
editorView,
|
||||
isLSPServerReady,
|
||||
isKclLspServerReady,
|
||||
isCopilotLspServerReady,
|
||||
setEditorView,
|
||||
setIsLSPServerReady,
|
||||
setIsKclLspServerReady,
|
||||
setIsCopilotLspServerReady,
|
||||
isShiftDown,
|
||||
} = useStore((s) => ({
|
||||
editorView: s.editorView,
|
||||
isLSPServerReady: s.isLSPServerReady,
|
||||
isKclLspServerReady: s.isKclLspServerReady,
|
||||
isCopilotLspServerReady: s.isCopilotLspServerReady,
|
||||
setEditorView: s.setEditorView,
|
||||
setIsLSPServerReady: s.setIsLSPServerReady,
|
||||
setIsKclLspServerReady: s.setIsKclLspServerReady,
|
||||
setIsCopilotLspServerReady: s.setIsCopilotLspServerReady,
|
||||
isShiftDown: s.isShiftDown,
|
||||
}))
|
||||
const { code, errors } = useKclContext()
|
||||
@ -66,7 +71,7 @@ export const TextEditor = ({
|
||||
state,
|
||||
} = useModelingContext()
|
||||
|
||||
const { settings: { context: { textWrapping } = {} } = {} } =
|
||||
const { settings: { context: { textWrapping } = {} } = {}, auth } =
|
||||
useSettingsAuthContext()
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const { enable: convertEnabled, handleClick: convertCallback } =
|
||||
@ -75,20 +80,20 @@ export const TextEditor = ({
|
||||
// So this is a bit weird, we need to initialize the lsp server and client.
|
||||
// But the server happens async so we break this into two parts.
|
||||
// Below is the client and server promise.
|
||||
const { lspClient } = useMemo(() => {
|
||||
const { lspClient: kclLspClient } = useMemo(() => {
|
||||
const intoServer: IntoServer = new IntoServer()
|
||||
const fromServer: FromServer = FromServer.create()
|
||||
const client = new Client(fromServer, intoServer)
|
||||
if (!TEST) {
|
||||
Server.initialize(intoServer, fromServer).then((lspServer) => {
|
||||
lspServer.start()
|
||||
setIsLSPServerReady(true)
|
||||
lspServer.start('kcl')
|
||||
setIsKclLspServerReady(true)
|
||||
})
|
||||
}
|
||||
|
||||
const lspClient = new LanguageServerClient({ client })
|
||||
return { lspClient }
|
||||
}, [setIsLSPServerReady])
|
||||
}, [setIsKclLspServerReady])
|
||||
|
||||
// Here we initialize the plugin which will start the client.
|
||||
// When we have multi-file support the name of the file will be a dep of
|
||||
@ -97,19 +102,57 @@ export const TextEditor = ({
|
||||
// We do not want to restart the server, its just wasteful.
|
||||
const kclLSP = useMemo(() => {
|
||||
let plugin = null
|
||||
if (isLSPServerReady && !TEST) {
|
||||
if (isKclLspServerReady && !TEST) {
|
||||
// Set up the lsp plugin.
|
||||
const lsp = kclLanguage({
|
||||
// When we have more than one file, we'll need to change this.
|
||||
documentUri: `file:///we-just-have-one-file-for-now.kcl`,
|
||||
workspaceFolders: null,
|
||||
client: lspClient,
|
||||
client: kclLspClient,
|
||||
})
|
||||
|
||||
plugin = lsp
|
||||
}
|
||||
return plugin
|
||||
}, [lspClient, isLSPServerReady])
|
||||
}, [kclLspClient, isKclLspServerReady])
|
||||
|
||||
const { lspClient: copilotLspClient } = useMemo(() => {
|
||||
const intoServer: IntoServer = new IntoServer()
|
||||
const fromServer: FromServer = FromServer.create()
|
||||
const client = new Client(fromServer, intoServer)
|
||||
if (!TEST) {
|
||||
Server.initialize(intoServer, fromServer).then((lspServer) => {
|
||||
const token = auth?.context?.token
|
||||
lspServer.start('copilot', token)
|
||||
setIsCopilotLspServerReady(true)
|
||||
})
|
||||
}
|
||||
|
||||
const lspClient = new LanguageServerClient({ client })
|
||||
return { lspClient }
|
||||
}, [setIsCopilotLspServerReady])
|
||||
|
||||
// Here we initialize the plugin which will start the client.
|
||||
// When we have multi-file support the name of the file will be a dep of
|
||||
// this use memo, as well as the directory structure, which I think is
|
||||
// a good setup because it will restart the client but not the server :)
|
||||
// We do not want to restart the server, its just wasteful.
|
||||
const copilotLSP = useMemo(() => {
|
||||
let plugin = null
|
||||
if (isCopilotLspServerReady && !TEST) {
|
||||
// Set up the lsp plugin.
|
||||
const lsp = copilotBundle({
|
||||
// When we have more than one file, we'll need to change this.
|
||||
documentUri: `file:///we-just-have-one-file-for-now.kcl`,
|
||||
workspaceFolders: null,
|
||||
client: copilotLspClient,
|
||||
allowHTMLContent: true,
|
||||
})
|
||||
|
||||
plugin = lsp
|
||||
}
|
||||
return plugin
|
||||
}, [copilotLspClient, isCopilotLspServerReady])
|
||||
|
||||
// const onChange = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
|
||||
const onChange = (newCode: string) => {
|
||||
@ -184,6 +227,7 @@ export const TextEditor = ({
|
||||
] as Extension[]
|
||||
|
||||
if (kclLSP) extensions.push(kclLSP)
|
||||
if (copilotLSP) extensions.push(copilotLSP)
|
||||
|
||||
// These extensions have proven to mess with vitest
|
||||
if (!TEST) {
|
||||
|
503
src/editor/copilot/index.ts
Normal file
503
src/editor/copilot/index.ts
Normal file
@ -0,0 +1,503 @@
|
||||
/// Thanks to the Cursor folks for their heavy lifting here.
|
||||
import { indentUnit } from '@codemirror/language'
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
} from '@codemirror/view'
|
||||
import {
|
||||
Annotation,
|
||||
EditorState,
|
||||
Extension,
|
||||
Facet,
|
||||
Prec,
|
||||
StateEffect,
|
||||
StateField,
|
||||
Transaction,
|
||||
} from '@codemirror/state'
|
||||
import { completionStatus } from '@codemirror/autocomplete'
|
||||
import { docPathFacet, offsetToPos, posToOffset } from 'editor/lsp/util'
|
||||
import { LanguageServerPlugin } from 'editor/lsp/plugin'
|
||||
import { LanguageServerOptions } from 'editor/lsp/plugin'
|
||||
import { LanguageServerClient } from 'editor/lsp'
|
||||
|
||||
// Create Facet for the current docPath
|
||||
export const docPath = Facet.define<string, string>({
|
||||
combine(value: readonly string[]) {
|
||||
return value[value.length - 1]
|
||||
},
|
||||
})
|
||||
|
||||
export const relDocPath = Facet.define<string, string>({
|
||||
combine(value: readonly string[]) {
|
||||
return value[value.length - 1]
|
||||
},
|
||||
})
|
||||
|
||||
const ghostMark = Decoration.mark({ class: 'cm-ghostText' })
|
||||
|
||||
interface Suggestion {
|
||||
text: string
|
||||
displayText: string
|
||||
cursorPos: number
|
||||
startPos: number
|
||||
endPos: number
|
||||
endReplacement: number
|
||||
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
|
||||
displayPos: number
|
||||
startPos: number
|
||||
endGhostText: number
|
||||
endReplacement: number
|
||||
endPos: number
|
||||
decorations: DecorationSet
|
||||
weirdInsert: boolean
|
||||
uuid: string
|
||||
}
|
||||
|
||||
export const completionDecoration = StateField.define<CompletionState>({
|
||||
create(_state: EditorState) {
|
||||
return { ghostText: null }
|
||||
},
|
||||
update(state: CompletionState, transaction: Transaction) {
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(addSuggestion)) {
|
||||
// When adding a suggestion, we set th ghostText
|
||||
const {
|
||||
text,
|
||||
displayText,
|
||||
endReplacement,
|
||||
cursorPos,
|
||||
startPos,
|
||||
endPos,
|
||||
uuid,
|
||||
} = effect.value
|
||||
const endGhostText = cursorPos + displayText.length
|
||||
const decorations = Decoration.set([
|
||||
ghostMark.range(cursorPos, endGhostText),
|
||||
])
|
||||
return {
|
||||
ghostText: {
|
||||
text,
|
||||
displayText,
|
||||
startPos,
|
||||
endPos,
|
||||
decorations,
|
||||
displayPos: cursorPos,
|
||||
endReplacement,
|
||||
endGhostText,
|
||||
weirdInsert: false,
|
||||
uuid,
|
||||
},
|
||||
}
|
||||
} else if (effect.is(acceptSuggestion)) {
|
||||
if (state.ghostText) {
|
||||
return { ghostText: null }
|
||||
}
|
||||
} else if (effect.is(typeFirst)) {
|
||||
const numChars = effect.value
|
||||
if (state.ghostText && !state.ghostText.weirdInsert) {
|
||||
let {
|
||||
text,
|
||||
displayText,
|
||||
displayPos,
|
||||
startPos,
|
||||
endPos,
|
||||
endGhostText,
|
||||
decorations,
|
||||
endReplacement,
|
||||
uuid,
|
||||
} = state.ghostText
|
||||
|
||||
displayPos += numChars
|
||||
|
||||
displayText = displayText.slice(numChars)
|
||||
|
||||
if (startPos === endGhostText) {
|
||||
return { ghostText: null }
|
||||
} else {
|
||||
decorations = Decoration.set([
|
||||
ghostMark.range(displayPos, endGhostText),
|
||||
])
|
||||
return {
|
||||
ghostText: {
|
||||
text,
|
||||
displayText,
|
||||
startPos,
|
||||
endPos,
|
||||
decorations,
|
||||
endGhostText,
|
||||
endReplacement,
|
||||
uuid,
|
||||
displayPos,
|
||||
weirdInsert: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (effect.is(clearSuggestion)) {
|
||||
return { ghostText: null }
|
||||
}
|
||||
}
|
||||
|
||||
// if (transaction.docChanged && state.ghostText) {
|
||||
// if (transaction.
|
||||
// onsole.log({changes: transaction.changes, transaction})
|
||||
// const newGhostText = state.ghostText.decorations.map(transaction.changes)
|
||||
// return {ghostText: {...state.ghostText, decorations: newGhostText}};
|
||||
// }
|
||||
|
||||
return state
|
||||
},
|
||||
provide: (field) =>
|
||||
EditorView.decorations.from(field, (value) =>
|
||||
value.ghostText ? value.ghostText.decorations : Decoration.none
|
||||
),
|
||||
})
|
||||
|
||||
const copilotEvent = Annotation.define<null>()
|
||||
|
||||
/****************************************************************************
|
||||
************************* 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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)],
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const completionPlugin = (copilotClient: LanguageServerClient) =>
|
||||
EditorView.domEventHandlers({
|
||||
keydown(event, view) {
|
||||
if (
|
||||
event.key !== 'Shift' &&
|
||||
event.key !== 'Control' &&
|
||||
event.key !== 'Alt' &&
|
||||
event.key !== 'Meta'
|
||||
) {
|
||||
return sameKeyCommand(copilotClient, view, 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 path = state.facet(docPath)
|
||||
const relativePath = state.facet(relDocPath)
|
||||
const languageId = 'kcl'
|
||||
|
||||
// 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: `file://${path}`,
|
||||
relativePath,
|
||||
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 function copilotServer(options: LanguageServerOptions) {
|
||||
let plugin: LanguageServerPlugin
|
||||
return ViewPlugin.define(
|
||||
(view) =>
|
||||
(plugin = new LanguageServerPlugin(view, options.allowHTMLContent))
|
||||
)
|
||||
}
|
||||
|
||||
export const copilotBundle = (options: LanguageServerOptions): Extension => [
|
||||
docPath.of(options.documentUri.split('/').pop()!),
|
||||
docPathFacet.of(options.documentUri.split('/').pop()!),
|
||||
relDocPath.of(options.documentUri.replace('file://', '')),
|
||||
completionDecoration,
|
||||
Prec.highest(completionPlugin(options.client)),
|
||||
Prec.highest(viewCompletionPlugin(options.client)),
|
||||
completionRequester(options.client),
|
||||
copilotServer(options),
|
||||
]
|
@ -3,6 +3,67 @@ import Client from './client'
|
||||
import { LanguageServerPlugin } from './plugin'
|
||||
import { SemanticToken, deserializeTokens } from './semantic_tokens'
|
||||
|
||||
export interface CopilotGetCompletionsParams {
|
||||
doc: {
|
||||
source: string
|
||||
tabSize: number
|
||||
indentSize: number
|
||||
insertSpaces: boolean
|
||||
path: string
|
||||
uri: string
|
||||
relativePath: string
|
||||
languageId: string
|
||||
position: {
|
||||
line: number
|
||||
character: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CopilotGetCompletionsResult {
|
||||
completions: {
|
||||
text: string
|
||||
position: {
|
||||
line: number
|
||||
character: number
|
||||
}
|
||||
uuid: string
|
||||
range: {
|
||||
start: {
|
||||
line: number
|
||||
character: number
|
||||
}
|
||||
end: {
|
||||
line: number
|
||||
character: number
|
||||
}
|
||||
}
|
||||
displayText: string
|
||||
point: {
|
||||
line: number
|
||||
character: number
|
||||
}
|
||||
region: {
|
||||
start: {
|
||||
line: number
|
||||
character: number
|
||||
}
|
||||
end: {
|
||||
line: number
|
||||
character: number
|
||||
}
|
||||
}
|
||||
}[]
|
||||
}
|
||||
|
||||
interface CopilotAcceptCompletionParams {
|
||||
uuid: string
|
||||
}
|
||||
|
||||
interface CopilotRejectCompletionParams {
|
||||
uuids: string[]
|
||||
}
|
||||
|
||||
// https://microsoft.github.io/language-server-protocol/specifications/specification-current/
|
||||
|
||||
// Client to server then server to client
|
||||
@ -17,6 +78,9 @@ interface LSPRequestMap {
|
||||
LSP.SemanticTokensParams,
|
||||
LSP.SemanticTokens
|
||||
]
|
||||
getCompletions: [CopilotGetCompletionsParams, CopilotGetCompletionsResult]
|
||||
notifyAccepted: [CopilotAcceptCompletionParams, any]
|
||||
notifyRejected: [CopilotRejectCompletionParams, any]
|
||||
}
|
||||
|
||||
// Client to server
|
||||
@ -55,6 +119,7 @@ export class LanguageServerClient {
|
||||
|
||||
private isUpdatingSemanticTokens: boolean = false
|
||||
private semanticTokens: SemanticToken[] = []
|
||||
private queuedUids: string[] = []
|
||||
|
||||
constructor(options: LanguageServerClientOptions) {
|
||||
this.plugins = []
|
||||
@ -62,6 +127,7 @@ export class LanguageServerClient {
|
||||
|
||||
this.ready = false
|
||||
|
||||
this.queuedUids = []
|
||||
this.initializePromise = this.initialize()
|
||||
}
|
||||
|
||||
@ -145,6 +211,33 @@ export class LanguageServerClient {
|
||||
return this.client.notify(method, params)
|
||||
}
|
||||
|
||||
async getCompletion(params: CopilotGetCompletionsParams) {
|
||||
const response = await this.request('getCompletions', params)
|
||||
//
|
||||
this.queuedUids = [...response.completions.map((c) => c.uuid)]
|
||||
return response
|
||||
}
|
||||
|
||||
async accept(uuid: string) {
|
||||
const badUids = this.queuedUids.filter((u) => u !== uuid)
|
||||
this.queuedUids = []
|
||||
await this.acceptCompletion({ uuid })
|
||||
await this.rejectCompletions({ uuids: badUids })
|
||||
}
|
||||
|
||||
async reject() {
|
||||
const badUids = this.queuedUids
|
||||
this.queuedUids = []
|
||||
return await this.rejectCompletions({ uuids: badUids })
|
||||
}
|
||||
|
||||
async acceptCompletion(params: CopilotAcceptCompletionParams) {
|
||||
return await this.request('notifyAccepted', params)
|
||||
}
|
||||
async rejectCompletions(params: CopilotRejectCompletionParams) {
|
||||
return await this.request('notifyRejected', params)
|
||||
}
|
||||
|
||||
private processNotification(notification: Notification) {
|
||||
for (const plugin of this.plugins) plugin.processNotification(notification)
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
NodeSet,
|
||||
} from '@lezer/common'
|
||||
import { LanguageServerClient } from '.'
|
||||
import { posToOffset } from './plugin'
|
||||
import { posToOffset } from 'editor/lsp/util'
|
||||
import { SemanticToken } from './semantic_tokens'
|
||||
import { DocInput } from '@codemirror/language'
|
||||
import { tags, styleTags } from '@lezer/highlight'
|
||||
|
@ -22,10 +22,10 @@ import type {
|
||||
} from '@codemirror/autocomplete'
|
||||
import type { PublishDiagnosticsParams } from 'vscode-languageserver-protocol'
|
||||
import type { ViewUpdate, PluginValue } from '@codemirror/view'
|
||||
import type { Text } from '@codemirror/state'
|
||||
import type * as LSP from 'vscode-languageserver-protocol'
|
||||
import { LanguageServerClient, Notification } from '.'
|
||||
import { Marked } from '@ts-stack/markdown'
|
||||
import { offsetToPos, posToOffset } from 'editor/lsp/util'
|
||||
|
||||
const changesDelay = 500
|
||||
|
||||
@ -343,24 +343,6 @@ export function kclPlugin(options: LanguageServerOptions) {
|
||||
]
|
||||
}
|
||||
|
||||
export function posToOffset(
|
||||
doc: Text,
|
||||
pos: { line: number; character: number }
|
||||
): number | undefined {
|
||||
if (pos.line >= doc.lines) return
|
||||
const offset = doc.line(pos.line + 1).from + pos.character
|
||||
if (offset > doc.length) return
|
||||
return offset
|
||||
}
|
||||
|
||||
function offsetToPos(doc: Text, offset: number) {
|
||||
const line = doc.lineAt(offset)
|
||||
return {
|
||||
line: line.number - 1,
|
||||
character: offset - line.from,
|
||||
}
|
||||
}
|
||||
|
||||
function formatContents(
|
||||
contents: LSP.MarkupContent | LSP.MarkedString | LSP.MarkedString[]
|
||||
): string {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import init, {
|
||||
copilot_lsp_run,
|
||||
InitOutput,
|
||||
lsp_run,
|
||||
kcl_lsp_run,
|
||||
ServerConfig,
|
||||
} from '../../wasm-lib/pkg/wasm_lib'
|
||||
import { FromServer, IntoServer } from './codec'
|
||||
@ -29,8 +30,15 @@ export default class Server {
|
||||
return server
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
async start(type_: 'kcl' | 'copilot', token?: string): Promise<void> {
|
||||
const config = new ServerConfig(this.#intoServer, this.#fromServer)
|
||||
await lsp_run(config)
|
||||
if (type_ === 'copilot') {
|
||||
if (!token) {
|
||||
throw new Error('auth token is required for copilot')
|
||||
}
|
||||
await copilot_lsp_run(config, token)
|
||||
} else if (type_ === 'kcl') {
|
||||
await kcl_lsp_run(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
23
src/editor/lsp/util.ts
Normal file
23
src/editor/lsp/util.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Facet, Text } from '@codemirror/state'
|
||||
|
||||
export function posToOffset(
|
||||
doc: Text,
|
||||
pos: { line: number; character: number }
|
||||
): number | undefined {
|
||||
if (pos.line >= doc.lines) return
|
||||
const offset = doc.line(pos.line + 1).from + pos.character
|
||||
if (offset > doc.length) return
|
||||
return offset
|
||||
}
|
||||
|
||||
export function offsetToPos(doc: Text, offset: number) {
|
||||
const line = doc.lineAt(offset)
|
||||
return {
|
||||
line: line.number - 1,
|
||||
character: offset - line.from,
|
||||
}
|
||||
}
|
||||
|
||||
export const docPathFacet = Facet.define<string, string>({
|
||||
combine: (values) => values[values.length - 1],
|
||||
})
|
@ -219,3 +219,8 @@ code {
|
||||
word-break: normal;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.cm-ghostText,
|
||||
.cm-ghostText * {
|
||||
color: rgb(120, 120, 120, 0.8) !important;
|
||||
}
|
||||
|
@ -1653,7 +1653,7 @@ describe('parsing errors', () => {
|
||||
|
||||
let _theError
|
||||
try {
|
||||
const result = expect(parse(code))
|
||||
let _ = expect(parse(code))
|
||||
} catch (e) {
|
||||
_theError = e
|
||||
}
|
||||
|
@ -66,8 +66,10 @@ export interface StoreState {
|
||||
setMediaStream: (mediaStream: MediaStream) => void
|
||||
isStreamReady: boolean
|
||||
setIsStreamReady: (isStreamReady: boolean) => void
|
||||
isLSPServerReady: boolean
|
||||
setIsLSPServerReady: (isLSPServerReady: boolean) => void
|
||||
isKclLspServerReady: boolean
|
||||
isCopilotLspServerReady: boolean
|
||||
setIsKclLspServerReady: (isKclLspServerReady: boolean) => void
|
||||
setIsCopilotLspServerReady: (isCopilotLspServerReady: boolean) => void
|
||||
buttonDownInStream: number | undefined
|
||||
setButtonDownInStream: (buttonDownInStream: number | undefined) => void
|
||||
didDragInStream: boolean
|
||||
@ -120,8 +122,12 @@ export const useStore = create<StoreState>()(
|
||||
setMediaStream: (mediaStream) => set({ mediaStream }),
|
||||
isStreamReady: false,
|
||||
setIsStreamReady: (isStreamReady) => set({ isStreamReady }),
|
||||
isLSPServerReady: false,
|
||||
setIsLSPServerReady: (isLSPServerReady) => set({ isLSPServerReady }),
|
||||
isKclLspServerReady: false,
|
||||
isCopilotLspServerReady: false,
|
||||
setIsKclLspServerReady: (isKclLspServerReady) =>
|
||||
set({ isKclLspServerReady }),
|
||||
setIsCopilotLspServerReady: (isCopilotLspServerReady) =>
|
||||
set({ isCopilotLspServerReady }),
|
||||
buttonDownInStream: undefined,
|
||||
setButtonDownInStream: (buttonDownInStream) => {
|
||||
set({ buttonDownInStream })
|
||||
|
29
src/wasm-lib/Cargo.lock
generated
29
src/wasm-lib/Cargo.lock
generated
@ -522,9 +522,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.31"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38"
|
||||
checksum = "5bc015644b92d5890fab7489e49d21f879d5c990186827d42ec511919404f38b"
|
||||
dependencies = [
|
||||
"android-tzdata",
|
||||
"iana-time-zone",
|
||||
@ -532,7 +532,7 @@ dependencies = [
|
||||
"num-traits 0.2.17",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-targets 0.48.5",
|
||||
"windows-targets 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1902,6 +1902,7 @@ dependencies = [
|
||||
"parse-display 0.9.0",
|
||||
"pretty_assertions",
|
||||
"reqwest",
|
||||
"ropey",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@ -1931,9 +1932,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kittycad"
|
||||
version = "0.2.50"
|
||||
version = "0.2.53"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "441d8af990a3aab738f985328aa914a9eee5856131c4c6f1fd2bd61ba9d07f98"
|
||||
checksum = "a086e1a1bbddb3b38959c0f0ce6de6b3a3b7566e38e0b7d5fb101e91911beed4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@ -3130,10 +3131,12 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls 0.24.1",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
"winreg",
|
||||
@ -3257,6 +3260,16 @@ dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ropey"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"str_indices",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rtcp"
|
||||
version = "0.10.0"
|
||||
@ -3863,6 +3876,12 @@ dependencies = [
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "str_indices"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9557cb6521e8d009c51a8666f09356f4b817ba9ba0981a305bd86aee47bd35c"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.0"
|
||||
|
@ -58,7 +58,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
kittycad = { version = "0.2.50", default-features = false, features = ["js"] }
|
||||
kittycad = { version = "0.2.53", default-features = false, features = ["js", "requests"] }
|
||||
kittycad-execution-plan = { git = "https://github.com/KittyCAD/modeling-api", branch = "main" }
|
||||
kittycad-execution-plan-traits = "0.1.10"
|
||||
kittycad-modeling-session = { git = "https://github.com/KittyCAD/modeling-api", branch = "main" }
|
||||
|
@ -19,12 +19,15 @@ dashmap = "5.5.3"
|
||||
databake = { version = "0.1.7", features = ["derive"] }
|
||||
#derive-docs = { version = "0.1.6" }
|
||||
derive-docs = { path = "../derive-docs" }
|
||||
futures = { version = "0.3.30" }
|
||||
gltf-json = "1.4.0"
|
||||
kittycad = { workspace = true }
|
||||
kittycad-execution-plan-macros = { workspace = true }
|
||||
kittycad-execution-plan-traits = { workspace = true }
|
||||
lazy_static = "1.4.0"
|
||||
parse-display = "0.9.0"
|
||||
reqwest = { version = "0.11.24", default-features = false, features = ["stream", "rustls-tls"] }
|
||||
ropey = "1.6.1"
|
||||
schemars = { version = "0.8.16", features = ["impl_json_schema", "url", "uuid1"] }
|
||||
serde = { version = "1.0.193", features = ["derive"] }
|
||||
serde_json = "1.0.108"
|
||||
@ -43,8 +46,6 @@ web-sys = { version = "0.3.68", features = ["console"] }
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
approx = "0.5"
|
||||
bson = { version = "2.9.0", features = ["uuid-1", "chrono"] }
|
||||
futures = { version = "0.3.30" }
|
||||
reqwest = { version = "0.11.24", default-features = false }
|
||||
tokio = { version = "1.36.0", features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.21.0", features = ["rustls-tls-native-roots"] }
|
||||
tower-lsp = { version = "0.20.0", features = ["proposed"] }
|
||||
|
@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value as JValue};
|
||||
use tower_lsp::lsp_types::{CompletionItem, CompletionItemKind, DocumentSymbol, Range as LspRange, SymbolKind};
|
||||
|
||||
pub use self::{literal_value::LiteralValue, none::KclNone};
|
||||
pub use crate::ast::types::{literal_value::LiteralValue, none::KclNone};
|
||||
use crate::{
|
||||
docs::StdLibFn,
|
||||
errors::{KclError, KclErrorDetails},
|
||||
|
119
src/wasm-lib/kcl/src/server/backend.rs
Normal file
119
src/wasm-lib/kcl/src/server/backend.rs
Normal file
@ -0,0 +1,119 @@
|
||||
//! A shared backend trait for lsp servers memory and behavior.
|
||||
|
||||
use dashmap::DashMap;
|
||||
use tower_lsp::lsp_types::{
|
||||
CreateFilesParams, DeleteFilesParams, DidChangeConfigurationParams, DidChangeTextDocumentParams,
|
||||
DidChangeWatchedFilesParams, DidChangeWorkspaceFoldersParams, DidCloseTextDocumentParams,
|
||||
DidOpenTextDocumentParams, DidSaveTextDocumentParams, InitializedParams, MessageType, RenameFilesParams,
|
||||
TextDocumentItem,
|
||||
};
|
||||
|
||||
/// A trait for the backend of the language server.
|
||||
#[async_trait::async_trait]
|
||||
pub trait Backend {
|
||||
fn client(&self) -> tower_lsp::Client;
|
||||
|
||||
/// Get the current code map.
|
||||
fn current_code_map(&self) -> DashMap<String, String>;
|
||||
|
||||
/// Insert a new code map.
|
||||
fn insert_current_code_map(&self, uri: String, text: String);
|
||||
|
||||
/// On change event.
|
||||
async fn on_change(&self, params: TextDocumentItem);
|
||||
|
||||
async fn update_memory(&self, params: TextDocumentItem) {
|
||||
// Lets update the tokens.
|
||||
self.insert_current_code_map(params.uri.to_string(), params.text.clone());
|
||||
}
|
||||
|
||||
async fn do_initialized(&self, params: InitializedParams) {
|
||||
self.client()
|
||||
.log_message(MessageType::INFO, format!("initialized: {:?}", params))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn do_shutdown(&self) -> tower_lsp::jsonrpc::Result<()> {
|
||||
self.client()
|
||||
.log_message(MessageType::INFO, "shutdown".to_string())
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn do_did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
|
||||
self.client()
|
||||
.log_message(MessageType::INFO, format!("workspace folders changed: {:?}", params))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn do_did_change_configuration(&self, params: DidChangeConfigurationParams) {
|
||||
self.client()
|
||||
.log_message(MessageType::INFO, format!("configuration changed: {:?}", params))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn do_did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
|
||||
self.client()
|
||||
.log_message(MessageType::INFO, format!("watched files changed: {:?}", params))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn do_did_create_files(&self, params: CreateFilesParams) {
|
||||
self.client()
|
||||
.log_message(MessageType::INFO, format!("files created: {:?}", params))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn do_did_rename_files(&self, params: RenameFilesParams) {
|
||||
self.client()
|
||||
.log_message(MessageType::INFO, format!("files renamed: {:?}", params))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn do_did_delete_files(&self, params: DeleteFilesParams) {
|
||||
self.client()
|
||||
.log_message(MessageType::INFO, format!("files deleted: {:?}", params))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn do_did_open(&self, params: DidOpenTextDocumentParams) {
|
||||
let new_params = TextDocumentItem {
|
||||
uri: params.text_document.uri,
|
||||
text: params.text_document.text,
|
||||
version: params.text_document.version,
|
||||
language_id: params.text_document.language_id,
|
||||
};
|
||||
self.update_memory(new_params.clone()).await;
|
||||
self.on_change(new_params).await;
|
||||
}
|
||||
|
||||
async fn do_did_change(&self, mut params: DidChangeTextDocumentParams) {
|
||||
let new_params = TextDocumentItem {
|
||||
uri: params.text_document.uri,
|
||||
text: std::mem::take(&mut params.content_changes[0].text),
|
||||
version: params.text_document.version,
|
||||
language_id: Default::default(),
|
||||
};
|
||||
self.update_memory(new_params.clone()).await;
|
||||
self.on_change(new_params).await;
|
||||
}
|
||||
|
||||
async fn do_did_save(&self, params: DidSaveTextDocumentParams) {
|
||||
if let Some(text) = params.text {
|
||||
let new_params = TextDocumentItem {
|
||||
uri: params.text_document.uri,
|
||||
text,
|
||||
version: Default::default(),
|
||||
language_id: Default::default(),
|
||||
};
|
||||
self.update_memory(new_params.clone()).await;
|
||||
self.on_change(new_params).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_did_close(&self, params: DidCloseTextDocumentParams) {
|
||||
self.client()
|
||||
.log_message(MessageType::INFO, format!("document closed: {:?}", params))
|
||||
.await;
|
||||
}
|
||||
}
|
111
src/wasm-lib/kcl/src/server/copilot/cache.rs
Normal file
111
src/wasm-lib/kcl/src/server/copilot/cache.rs
Normal file
@ -0,0 +1,111 @@
|
||||
//! The cache.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt::Debug,
|
||||
sync::{Mutex, RwLock},
|
||||
};
|
||||
|
||||
use crate::server::copilot::types::CopilotCompletionResponse;
|
||||
|
||||
// if file changes, keep the cache.
|
||||
// if line number is different for an existing file, clean.
|
||||
#[derive(Debug)]
|
||||
pub struct CopilotCache {
|
||||
inner: RwLock<HashMap<String, Mutex<CopilotCompletionResponse>>>,
|
||||
last_line: RwLock<HashMap<String, Mutex<u32>>>,
|
||||
}
|
||||
|
||||
impl Default for CopilotCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CopilotCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: RwLock::new(HashMap::new()),
|
||||
last_line: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_last_line(&self, uri: &String) -> Option<u32> {
|
||||
let Ok(inner) = self.last_line.read() else {
|
||||
return None;
|
||||
};
|
||||
let last_line = inner.get(uri);
|
||||
match last_line {
|
||||
Some(last_line) => {
|
||||
let Ok(last_line) = last_line.lock() else {
|
||||
return None;
|
||||
};
|
||||
Some(*last_line)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_cached_response(&self, uri: &String, _lnum: u32) -> Option<CopilotCompletionResponse> {
|
||||
let Ok(inner) = self.inner.read() else {
|
||||
return None;
|
||||
};
|
||||
let cache = inner.get(uri);
|
||||
match cache {
|
||||
Some(completion_response) => {
|
||||
let Ok(completion_response) = completion_response.lock() else {
|
||||
return None;
|
||||
};
|
||||
Some(completion_response.clone())
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_file_cache(&self, uri: &str, completion_response: CopilotCompletionResponse) {
|
||||
let Ok(mut inner) = self.inner.write() else {
|
||||
return;
|
||||
};
|
||||
inner.insert(uri.to_string(), Mutex::new(completion_response));
|
||||
}
|
||||
|
||||
fn set_last_line(&self, uri: &str, last_line: u32) {
|
||||
let Ok(mut inner) = self.last_line.write() else {
|
||||
return;
|
||||
};
|
||||
inner.insert(uri.to_string(), Mutex::new(last_line));
|
||||
}
|
||||
|
||||
pub fn get_cached_result(&self, uri: &String, last_line: u32) -> Option<CopilotCompletionResponse> {
|
||||
let Some(cached_line) = self.get_last_line(uri) else {
|
||||
return None;
|
||||
};
|
||||
if last_line != cached_line {
|
||||
return None;
|
||||
};
|
||||
self.get_cached_response(uri, last_line)
|
||||
}
|
||||
|
||||
pub fn set_cached_result(
|
||||
&self,
|
||||
uri: &String,
|
||||
lnum: &u32,
|
||||
completion_response: &CopilotCompletionResponse,
|
||||
) -> Option<CopilotCompletionResponse> {
|
||||
self.set_file_cache(uri, completion_response.clone());
|
||||
self.set_last_line(uri, *lnum);
|
||||
let Ok(inner) = self.inner.write() else {
|
||||
return None;
|
||||
};
|
||||
let cache = inner.get(uri);
|
||||
match cache {
|
||||
Some(completion_response) => {
|
||||
let Ok(completion_response) = completion_response.lock() else {
|
||||
return None;
|
||||
};
|
||||
Some(completion_response.clone())
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
259
src/wasm-lib/kcl/src/server/copilot/mod.rs
Normal file
259
src/wasm-lib/kcl/src/server/copilot/mod.rs
Normal file
@ -0,0 +1,259 @@
|
||||
//! The copilot lsp server for ghost text.
|
||||
|
||||
pub mod cache;
|
||||
pub mod types;
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
fmt::Debug,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_lsp::{
|
||||
jsonrpc::{Error, Result},
|
||||
lsp_types::{
|
||||
CreateFilesParams, DeleteFilesParams, DidChangeConfigurationParams, DidChangeTextDocumentParams,
|
||||
DidChangeWatchedFilesParams, DidChangeWorkspaceFoldersParams, DidCloseTextDocumentParams,
|
||||
DidOpenTextDocumentParams, DidSaveTextDocumentParams, InitializeParams, InitializeResult, InitializedParams,
|
||||
MessageType, OneOf, RenameFilesParams, ServerCapabilities, TextDocumentItem, TextDocumentSyncCapability,
|
||||
TextDocumentSyncKind, TextDocumentSyncOptions, WorkspaceFoldersServerCapabilities, WorkspaceServerCapabilities,
|
||||
},
|
||||
LanguageServer,
|
||||
};
|
||||
|
||||
use crate::server::{
|
||||
backend::Backend as _,
|
||||
copilot::types::{CopilotCompletionResponse, CopilotEditorInfo, CopilotLspCompletionParams, DocParams},
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct Success {
|
||||
success: bool,
|
||||
}
|
||||
impl Success {
|
||||
pub fn new(success: bool) -> Self {
|
||||
Self { success }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Backend {
|
||||
/// The client is used to send notifications and requests to the client.
|
||||
pub client: tower_lsp::Client,
|
||||
/// Current code.
|
||||
pub current_code_map: DashMap<String, String>,
|
||||
/// The token is used to authenticate requests to the API server.
|
||||
pub token: String,
|
||||
/// The editor info is used to store information about the editor.
|
||||
pub editor_info: Arc<RwLock<CopilotEditorInfo>>,
|
||||
/// The cache is used to store the results of previous requests.
|
||||
pub cache: cache::CopilotCache,
|
||||
}
|
||||
|
||||
// Implement the shared backend trait for the language server.
|
||||
#[async_trait::async_trait]
|
||||
impl crate::server::backend::Backend for Backend {
|
||||
fn client(&self) -> tower_lsp::Client {
|
||||
self.client.clone()
|
||||
}
|
||||
|
||||
fn current_code_map(&self) -> DashMap<String, String> {
|
||||
self.current_code_map.clone()
|
||||
}
|
||||
|
||||
fn insert_current_code_map(&self, uri: String, text: String) {
|
||||
self.current_code_map.insert(uri, text);
|
||||
}
|
||||
|
||||
async fn on_change(&self, _params: TextDocumentItem) {
|
||||
// We don't need to do anything here.
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
/// Get completions from the kittycad api.
|
||||
pub async fn get_completions(&self, language: String, prompt: String, suffix: String) -> Result<Vec<String>> {
|
||||
let body = kittycad::types::KclCodeCompletionRequest {
|
||||
prompt: Some(prompt.clone()),
|
||||
suffix: Some(suffix.clone()),
|
||||
max_tokens: Some(500),
|
||||
temperature: Some(1.0),
|
||||
top_p: Some(1.0),
|
||||
// We only handle one completion at a time, for now so don't even waste the tokens.
|
||||
n: Some(1),
|
||||
stop: Some(["unset".to_string()].to_vec()),
|
||||
nwo: None,
|
||||
// We haven't implemented streaming yet.
|
||||
stream: None,
|
||||
extra: Some(kittycad::types::KclCodeCompletionParams {
|
||||
language: Some(language.to_string()),
|
||||
next_indent: None,
|
||||
trim_by_indentation: Some(true),
|
||||
prompt_tokens: Some(prompt.len() as u32),
|
||||
suffix_tokens: Some(suffix.len() as u32),
|
||||
}),
|
||||
};
|
||||
|
||||
let kc_client = kittycad::Client::new(&self.token);
|
||||
let resp = kc_client
|
||||
.ai()
|
||||
.create_kcl_code_completions(&body)
|
||||
.await
|
||||
.map_err(|err| Error {
|
||||
code: tower_lsp::jsonrpc::ErrorCode::from(69),
|
||||
data: None,
|
||||
message: Cow::from(format!("Failed to get completions from zoo api: {}", err)),
|
||||
})?;
|
||||
Ok(resp.completions)
|
||||
}
|
||||
|
||||
pub async fn set_editor_info(&self, params: CopilotEditorInfo) -> Result<Success> {
|
||||
self.client.log_message(MessageType::INFO, "setEditorInfo").await;
|
||||
let copy = Arc::clone(&self.editor_info);
|
||||
let mut lock = copy.write().map_err(|err| Error {
|
||||
code: tower_lsp::jsonrpc::ErrorCode::from(69),
|
||||
data: None,
|
||||
message: Cow::from(format!("Failed lock: {}", err)),
|
||||
})?;
|
||||
*lock = params;
|
||||
Ok(Success::new(true))
|
||||
}
|
||||
|
||||
pub fn get_doc_params(&self, params: &CopilotLspCompletionParams) -> Result<DocParams> {
|
||||
let pos = params.doc.position;
|
||||
let uri = params.doc.uri.to_string();
|
||||
let rope = ropey::Rope::from_str(¶ms.doc.source);
|
||||
let offset = crate::server::util::position_to_offset(pos, &rope).unwrap_or_default();
|
||||
|
||||
Ok(DocParams {
|
||||
uri: uri.to_string(),
|
||||
pos,
|
||||
language: params.doc.language_id.to_string(),
|
||||
prefix: crate::server::util::get_text_before(offset, &rope).unwrap_or_default(),
|
||||
suffix: crate::server::util::get_text_after(offset, &rope).unwrap_or_default(),
|
||||
line_before: crate::server::util::get_line_before(pos, &rope).unwrap_or_default(),
|
||||
rope,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_completions_cycling(
|
||||
&self,
|
||||
params: CopilotLspCompletionParams,
|
||||
) -> Result<CopilotCompletionResponse> {
|
||||
let doc_params = self.get_doc_params(¶ms)?;
|
||||
let cached_result = self.cache.get_cached_result(&doc_params.uri, doc_params.pos.line);
|
||||
if let Some(cached_result) = cached_result {
|
||||
return Ok(cached_result);
|
||||
}
|
||||
|
||||
let doc_params = self.get_doc_params(¶ms)?;
|
||||
let line_before = doc_params.line_before.to_string();
|
||||
|
||||
// Let's not call it yet since it's not our model.
|
||||
/*let completion_list = self
|
||||
.get_completions(doc_params.language, doc_params.prefix, doc_params.suffix)
|
||||
.await
|
||||
.map_err(|err| Error {
|
||||
code: tower_lsp::jsonrpc::ErrorCode::from(69),
|
||||
data: None,
|
||||
message: Cow::from(format!("Failed to get completions: {}", err)),
|
||||
})?;*/
|
||||
let completion_list = vec![];
|
||||
|
||||
let response = CopilotCompletionResponse::from_str_vec(completion_list, line_before, doc_params.pos);
|
||||
self.cache
|
||||
.set_cached_result(&doc_params.uri, &doc_params.pos.line, &response);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn accept_completions(&self, params: Vec<String>) {
|
||||
self.client
|
||||
.log_message(MessageType::INFO, format!("Accepted completions: {:?}", params))
|
||||
.await;
|
||||
|
||||
// TODO: send telemetry data back out that we accepted the completions
|
||||
}
|
||||
|
||||
pub async fn reject_completions(&self, params: Vec<String>) {
|
||||
self.client
|
||||
.log_message(MessageType::INFO, format!("Rejected completions: {:?}", params))
|
||||
.await;
|
||||
|
||||
// TODO: send telemetry data back out that we rejected the completions
|
||||
}
|
||||
}
|
||||
|
||||
#[tower_lsp::async_trait]
|
||||
impl LanguageServer for Backend {
|
||||
async fn initialize(&self, _: InitializeParams) -> Result<InitializeResult> {
|
||||
Ok(InitializeResult {
|
||||
capabilities: ServerCapabilities {
|
||||
text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
|
||||
open_close: Some(true),
|
||||
change: Some(TextDocumentSyncKind::FULL),
|
||||
..Default::default()
|
||||
})),
|
||||
workspace: Some(WorkspaceServerCapabilities {
|
||||
workspace_folders: Some(WorkspaceFoldersServerCapabilities {
|
||||
supported: Some(true),
|
||||
change_notifications: Some(OneOf::Left(true)),
|
||||
}),
|
||||
file_operations: None,
|
||||
}),
|
||||
..ServerCapabilities::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
async fn initialized(&self, params: InitializedParams) {
|
||||
self.do_initialized(params).await
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> tower_lsp::jsonrpc::Result<()> {
|
||||
self.do_shutdown().await
|
||||
}
|
||||
|
||||
async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
|
||||
self.do_did_change_workspace_folders(params).await
|
||||
}
|
||||
|
||||
async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
|
||||
self.do_did_change_configuration(params).await
|
||||
}
|
||||
|
||||
async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
|
||||
self.do_did_change_watched_files(params).await
|
||||
}
|
||||
|
||||
async fn did_create_files(&self, params: CreateFilesParams) {
|
||||
self.do_did_create_files(params).await
|
||||
}
|
||||
|
||||
async fn did_rename_files(&self, params: RenameFilesParams) {
|
||||
self.do_did_rename_files(params).await
|
||||
}
|
||||
|
||||
async fn did_delete_files(&self, params: DeleteFilesParams) {
|
||||
self.do_did_delete_files(params).await
|
||||
}
|
||||
|
||||
async fn did_open(&self, params: DidOpenTextDocumentParams) {
|
||||
self.do_did_open(params).await
|
||||
}
|
||||
|
||||
async fn did_change(&self, params: DidChangeTextDocumentParams) {
|
||||
self.do_did_change(params.clone()).await;
|
||||
}
|
||||
|
||||
async fn did_save(&self, params: DidSaveTextDocumentParams) {
|
||||
self.do_did_save(params).await
|
||||
}
|
||||
|
||||
async fn did_close(&self, params: DidCloseTextDocumentParams) {
|
||||
self.do_did_close(params).await
|
||||
}
|
||||
}
|
130
src/wasm-lib/kcl/src/server/copilot/types.rs
Normal file
130
src/wasm-lib/kcl/src/server/copilot/types.rs
Normal file
@ -0,0 +1,130 @@
|
||||
//! Types we need for communication with the server.
|
||||
|
||||
use ropey::Rope;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_lsp::lsp_types::{Position, Range};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CopilotCyclingCompletion {
|
||||
pub display_text: String, // partial text
|
||||
pub text: String, // fulltext
|
||||
pub range: Range, // start char always 0
|
||||
pub position: Position,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Choices {
|
||||
pub text: String,
|
||||
pub index: i16,
|
||||
pub finish_reason: Option<String>,
|
||||
pub logprobs: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct CopilotCompletionResponse {
|
||||
pub completions: Vec<CopilotCyclingCompletion>,
|
||||
pub cancellation_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl CopilotCompletionResponse {
|
||||
pub fn from_str_vec(str_vec: Vec<String>, line_before: String, pos: Position) -> Self {
|
||||
let completions = str_vec
|
||||
.iter()
|
||||
.map(|x| CopilotCyclingCompletion::new(x.to_string(), line_before.to_string(), pos))
|
||||
.collect();
|
||||
Self {
|
||||
completions,
|
||||
cancellation_reason: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CopilotCyclingCompletion {
|
||||
pub fn new(text: String, line_before: String, position: Position) -> Self {
|
||||
let display_text = text.clone();
|
||||
let text = format!("{}{}", line_before, text);
|
||||
let end_char = text.find('\n').unwrap_or(text.len()) as u32;
|
||||
Self {
|
||||
display_text, // partial text
|
||||
text, // fulltext
|
||||
range: Range {
|
||||
start: Position {
|
||||
character: 0,
|
||||
line: position.line,
|
||||
},
|
||||
end: Position {
|
||||
character: end_char,
|
||||
line: position.line,
|
||||
},
|
||||
}, // start char always 0
|
||||
position,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LanguageEntry {
|
||||
language_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EditorConfiguration {
|
||||
disabled_languages: Vec<LanguageEntry>,
|
||||
enable_auto_completions: bool,
|
||||
}
|
||||
|
||||
impl Default for EditorConfiguration {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
disabled_languages: vec![],
|
||||
enable_auto_completions: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EditorInfo {
|
||||
name: String,
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CopilotEditorInfo {
|
||||
editor_configuration: EditorConfiguration,
|
||||
editor_info: EditorInfo,
|
||||
editor_plugin_info: EditorInfo,
|
||||
}
|
||||
|
||||
pub struct DocParams {
|
||||
pub rope: Rope,
|
||||
pub uri: String,
|
||||
pub pos: Position,
|
||||
pub language: String,
|
||||
pub line_before: String,
|
||||
pub prefix: String,
|
||||
pub suffix: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CopilotLspCompletionParams {
|
||||
pub doc: CopilotDocParams,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CopilotDocParams {
|
||||
pub indent_size: u32,
|
||||
pub insert_spaces: bool,
|
||||
pub language_id: String,
|
||||
pub path: String,
|
||||
pub position: Position,
|
||||
pub relative_path: String,
|
||||
pub source: String,
|
||||
pub tab_size: u32,
|
||||
pub uri: String,
|
||||
}
|
692
src/wasm-lib/kcl/src/server/lsp/mod.rs
Normal file
692
src/wasm-lib/kcl/src/server/lsp/mod.rs
Normal file
@ -0,0 +1,692 @@
|
||||
//! Functions for the `kcl` lsp server.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
#[cfg(feature = "cli")]
|
||||
use clap::Parser;
|
||||
use dashmap::DashMap;
|
||||
use tower_lsp::{
|
||||
jsonrpc::Result as RpcResult,
|
||||
lsp_types::{
|
||||
CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionResponse, CreateFilesParams,
|
||||
DeleteFilesParams, DiagnosticOptions, DiagnosticServerCapabilities, DidChangeConfigurationParams,
|
||||
DidChangeTextDocumentParams, DidChangeWatchedFilesParams, DidChangeWorkspaceFoldersParams,
|
||||
DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams, DocumentDiagnosticParams,
|
||||
DocumentDiagnosticReport, DocumentDiagnosticReportResult, DocumentFilter, DocumentFormattingParams,
|
||||
DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, Documentation, FullDocumentDiagnosticReport,
|
||||
Hover, HoverContents, HoverParams, HoverProviderCapability, InitializeParams, InitializeResult,
|
||||
InitializedParams, InlayHint, InlayHintParams, InsertTextFormat, MarkupContent, MarkupKind, MessageType, OneOf,
|
||||
ParameterInformation, ParameterLabel, Position, RelatedFullDocumentDiagnosticReport, RenameFilesParams,
|
||||
RenameParams, SemanticToken, SemanticTokenType, SemanticTokens, SemanticTokensFullOptions,
|
||||
SemanticTokensLegend, SemanticTokensOptions, SemanticTokensParams, SemanticTokensRegistrationOptions,
|
||||
SemanticTokensResult, SemanticTokensServerCapabilities, ServerCapabilities, SignatureHelp,
|
||||
SignatureHelpOptions, SignatureHelpParams, SignatureInformation, StaticRegistrationOptions, TextDocumentItem,
|
||||
TextDocumentRegistrationOptions, TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
|
||||
TextEdit, WorkDoneProgressOptions, WorkspaceEdit, WorkspaceFoldersServerCapabilities,
|
||||
WorkspaceServerCapabilities,
|
||||
},
|
||||
Client, LanguageServer,
|
||||
};
|
||||
|
||||
use crate::{ast::types::VariableKind, executor::SourceRange, parser::PIPE_OPERATOR, server::backend::Backend as _};
|
||||
|
||||
/// A subcommand for running the server.
|
||||
#[derive(Clone, Debug)]
|
||||
#[cfg_attr(feature = "cli", derive(Parser))]
|
||||
pub struct Server {
|
||||
/// Port that the server should listen
|
||||
#[cfg_attr(feature = "cli", clap(long, default_value = "8080"))]
|
||||
pub socket: i32,
|
||||
|
||||
/// Listen over stdin and stdout instead of a tcp socket.
|
||||
#[cfg_attr(feature = "cli", clap(short, long, default_value = "false"))]
|
||||
pub stdio: bool,
|
||||
}
|
||||
|
||||
/// The lsp server backend.
|
||||
pub struct Backend {
|
||||
/// The client for the backend.
|
||||
pub client: Client,
|
||||
/// The stdlib completions for the language.
|
||||
pub stdlib_completions: HashMap<String, CompletionItem>,
|
||||
/// The stdlib signatures for the language.
|
||||
pub stdlib_signatures: HashMap<String, SignatureHelp>,
|
||||
/// The types of tokens the server supports.
|
||||
pub token_types: Vec<SemanticTokenType>,
|
||||
/// Token maps.
|
||||
pub token_map: DashMap<String, Vec<crate::token::Token>>,
|
||||
/// AST maps.
|
||||
pub ast_map: DashMap<String, crate::ast::types::Program>,
|
||||
/// Current code.
|
||||
pub current_code_map: DashMap<String, String>,
|
||||
/// Diagnostics.
|
||||
pub diagnostics_map: DashMap<String, DocumentDiagnosticReport>,
|
||||
/// Symbols map.
|
||||
pub symbols_map: DashMap<String, Vec<DocumentSymbol>>,
|
||||
/// Semantic tokens map.
|
||||
pub semantic_tokens_map: DashMap<String, Vec<SemanticToken>>,
|
||||
}
|
||||
|
||||
// Implement the shared backend trait for the language server.
|
||||
#[async_trait::async_trait]
|
||||
impl crate::server::backend::Backend for Backend {
|
||||
fn client(&self) -> Client {
|
||||
self.client.clone()
|
||||
}
|
||||
|
||||
fn current_code_map(&self) -> DashMap<String, String> {
|
||||
self.current_code_map.clone()
|
||||
}
|
||||
|
||||
fn insert_current_code_map(&self, uri: String, text: String) {
|
||||
self.current_code_map.insert(uri, text);
|
||||
}
|
||||
|
||||
async fn on_change(&self, params: TextDocumentItem) {
|
||||
// We already updated the code map in the shared backend.
|
||||
|
||||
// Lets update the tokens.
|
||||
let tokens = crate::token::lexer(¶ms.text);
|
||||
self.token_map.insert(params.uri.to_string(), tokens.clone());
|
||||
|
||||
// Update the semantic tokens map.
|
||||
let mut semantic_tokens = vec![];
|
||||
let mut last_position = Position::new(0, 0);
|
||||
for token in &tokens {
|
||||
let Ok(mut token_type) = SemanticTokenType::try_from(token.token_type) else {
|
||||
// We continue here because not all tokens can be converted this way, we will get
|
||||
// the rest from the ast.
|
||||
continue;
|
||||
};
|
||||
|
||||
if token.token_type == crate::token::TokenType::Word && self.stdlib_completions.contains_key(&token.value) {
|
||||
// This is a stdlib function.
|
||||
token_type = SemanticTokenType::FUNCTION;
|
||||
}
|
||||
|
||||
let token_type_index = match self.get_semantic_token_type_index(token_type.clone()) {
|
||||
Some(index) => index,
|
||||
// This is actually bad this should not fail.
|
||||
// TODO: ensure we never get here.
|
||||
None => {
|
||||
self.client
|
||||
.log_message(
|
||||
MessageType::INFO,
|
||||
format!("token type `{:?}` not accounted for", token_type),
|
||||
)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let source_range: SourceRange = token.clone().into();
|
||||
let position = source_range.start_to_lsp_position(¶ms.text);
|
||||
|
||||
let semantic_token = SemanticToken {
|
||||
delta_line: position.line - last_position.line,
|
||||
delta_start: if position.line != last_position.line {
|
||||
position.character
|
||||
} else {
|
||||
position.character - last_position.character
|
||||
},
|
||||
length: token.value.len() as u32,
|
||||
token_type: token_type_index as u32,
|
||||
token_modifiers_bitset: 0,
|
||||
};
|
||||
|
||||
semantic_tokens.push(semantic_token);
|
||||
|
||||
last_position = position;
|
||||
}
|
||||
self.semantic_tokens_map.insert(params.uri.to_string(), semantic_tokens);
|
||||
|
||||
// Lets update the ast.
|
||||
let parser = crate::parser::Parser::new(tokens);
|
||||
let result = parser.ast();
|
||||
let ast = match result {
|
||||
Ok(ast) => ast,
|
||||
Err(e) => {
|
||||
let diagnostic = e.to_lsp_diagnostic(¶ms.text);
|
||||
// We got errors, update the diagnostics.
|
||||
self.diagnostics_map.insert(
|
||||
params.uri.to_string(),
|
||||
DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
|
||||
related_documents: None,
|
||||
full_document_diagnostic_report: FullDocumentDiagnosticReport {
|
||||
result_id: None,
|
||||
items: vec![diagnostic.clone()],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Publish the diagnostic.
|
||||
// If the client supports it.
|
||||
self.client
|
||||
.publish_diagnostics(params.uri, vec![diagnostic], None)
|
||||
.await;
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Update the symbols map.
|
||||
self.symbols_map
|
||||
.insert(params.uri.to_string(), ast.get_lsp_symbols(¶ms.text));
|
||||
|
||||
self.ast_map.insert(params.uri.to_string(), ast);
|
||||
// Lets update the diagnostics, since we got no errors.
|
||||
self.diagnostics_map.insert(
|
||||
params.uri.to_string(),
|
||||
DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
|
||||
related_documents: None,
|
||||
full_document_diagnostic_report: FullDocumentDiagnosticReport {
|
||||
result_id: None,
|
||||
items: vec![],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Publish the diagnostic, we reset it here so the client knows the code compiles now.
|
||||
// If the client supports it.
|
||||
self.client.publish_diagnostics(params.uri.clone(), vec![], None).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
fn get_semantic_token_type_index(&self, token_type: SemanticTokenType) -> Option<usize> {
|
||||
self.token_types.iter().position(|x| *x == token_type)
|
||||
}
|
||||
|
||||
async fn completions_get_variables_from_ast(&self, file_name: &str) -> Vec<CompletionItem> {
|
||||
let mut completions = vec![];
|
||||
|
||||
let ast = match self.ast_map.get(file_name) {
|
||||
Some(ast) => ast,
|
||||
None => return completions,
|
||||
};
|
||||
|
||||
for item in &ast.body {
|
||||
match item {
|
||||
crate::ast::types::BodyItem::ExpressionStatement(_) => continue,
|
||||
crate::ast::types::BodyItem::ReturnStatement(_) => continue,
|
||||
crate::ast::types::BodyItem::VariableDeclaration(variable) => {
|
||||
// We only want to complete variables.
|
||||
for declaration in &variable.declarations {
|
||||
completions.push(CompletionItem {
|
||||
label: declaration.id.name.to_string(),
|
||||
label_details: None,
|
||||
kind: Some(match variable.kind {
|
||||
crate::ast::types::VariableKind::Let => CompletionItemKind::VARIABLE,
|
||||
crate::ast::types::VariableKind::Const => CompletionItemKind::CONSTANT,
|
||||
crate::ast::types::VariableKind::Var => CompletionItemKind::VARIABLE,
|
||||
crate::ast::types::VariableKind::Fn => CompletionItemKind::FUNCTION,
|
||||
}),
|
||||
detail: Some(variable.kind.to_string()),
|
||||
documentation: None,
|
||||
deprecated: None,
|
||||
preselect: None,
|
||||
sort_text: None,
|
||||
filter_text: None,
|
||||
insert_text: None,
|
||||
insert_text_format: None,
|
||||
insert_text_mode: None,
|
||||
text_edit: None,
|
||||
additional_text_edits: None,
|
||||
command: None,
|
||||
commit_characters: None,
|
||||
data: None,
|
||||
tags: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
completions
|
||||
}
|
||||
}
|
||||
|
||||
#[tower_lsp::async_trait]
|
||||
impl LanguageServer for Backend {
|
||||
async fn initialize(&self, params: InitializeParams) -> RpcResult<InitializeResult> {
|
||||
self.client
|
||||
.log_message(MessageType::INFO, format!("initialize: {:?}", params))
|
||||
.await;
|
||||
|
||||
Ok(InitializeResult {
|
||||
capabilities: ServerCapabilities {
|
||||
completion_provider: Some(CompletionOptions {
|
||||
resolve_provider: Some(false),
|
||||
trigger_characters: Some(vec![".".to_string()]),
|
||||
work_done_progress_options: Default::default(),
|
||||
all_commit_characters: None,
|
||||
..Default::default()
|
||||
}),
|
||||
diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
|
||||
..Default::default()
|
||||
})),
|
||||
document_formatting_provider: Some(OneOf::Left(true)),
|
||||
document_symbol_provider: Some(OneOf::Left(true)),
|
||||
hover_provider: Some(HoverProviderCapability::Simple(true)),
|
||||
inlay_hint_provider: Some(OneOf::Left(true)),
|
||||
rename_provider: Some(OneOf::Left(true)),
|
||||
semantic_tokens_provider: Some(SemanticTokensServerCapabilities::SemanticTokensRegistrationOptions(
|
||||
SemanticTokensRegistrationOptions {
|
||||
text_document_registration_options: {
|
||||
TextDocumentRegistrationOptions {
|
||||
document_selector: Some(vec![DocumentFilter {
|
||||
language: Some("kcl".to_string()),
|
||||
scheme: Some("file".to_string()),
|
||||
pattern: None,
|
||||
}]),
|
||||
}
|
||||
},
|
||||
semantic_tokens_options: SemanticTokensOptions {
|
||||
work_done_progress_options: WorkDoneProgressOptions::default(),
|
||||
legend: SemanticTokensLegend {
|
||||
token_types: self.token_types.clone(),
|
||||
token_modifiers: vec![],
|
||||
},
|
||||
range: Some(false),
|
||||
full: Some(SemanticTokensFullOptions::Bool(true)),
|
||||
},
|
||||
static_registration_options: StaticRegistrationOptions::default(),
|
||||
},
|
||||
)),
|
||||
signature_help_provider: Some(SignatureHelpOptions {
|
||||
trigger_characters: None,
|
||||
retrigger_characters: None,
|
||||
..Default::default()
|
||||
}),
|
||||
text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
|
||||
open_close: Some(true),
|
||||
change: Some(TextDocumentSyncKind::FULL),
|
||||
..Default::default()
|
||||
})),
|
||||
workspace: Some(WorkspaceServerCapabilities {
|
||||
workspace_folders: Some(WorkspaceFoldersServerCapabilities {
|
||||
supported: Some(true),
|
||||
change_notifications: Some(OneOf::Left(true)),
|
||||
}),
|
||||
file_operations: None,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
async fn initialized(&self, params: InitializedParams) {
|
||||
self.do_initialized(params).await
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> RpcResult<()> {
|
||||
self.do_shutdown().await
|
||||
}
|
||||
|
||||
async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
|
||||
self.do_did_change_workspace_folders(params).await
|
||||
}
|
||||
|
||||
async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
|
||||
self.do_did_change_configuration(params).await
|
||||
}
|
||||
|
||||
async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
|
||||
self.do_did_change_watched_files(params).await
|
||||
}
|
||||
|
||||
async fn did_create_files(&self, params: CreateFilesParams) {
|
||||
self.do_did_create_files(params).await
|
||||
}
|
||||
|
||||
async fn did_rename_files(&self, params: RenameFilesParams) {
|
||||
self.do_did_rename_files(params).await
|
||||
}
|
||||
|
||||
async fn did_delete_files(&self, params: DeleteFilesParams) {
|
||||
self.do_did_delete_files(params).await
|
||||
}
|
||||
|
||||
async fn did_open(&self, params: DidOpenTextDocumentParams) {
|
||||
self.do_did_open(params).await
|
||||
}
|
||||
|
||||
async fn did_change(&self, params: DidChangeTextDocumentParams) {
|
||||
self.do_did_change(params.clone()).await;
|
||||
}
|
||||
|
||||
async fn did_save(&self, params: DidSaveTextDocumentParams) {
|
||||
self.do_did_save(params).await
|
||||
}
|
||||
|
||||
async fn did_close(&self, params: DidCloseTextDocumentParams) {
|
||||
self.do_did_close(params).await
|
||||
}
|
||||
|
||||
async fn hover(&self, params: HoverParams) -> RpcResult<Option<Hover>> {
|
||||
let filename = params.text_document_position_params.text_document.uri.to_string();
|
||||
|
||||
let Some(current_code) = self.current_code_map.get(&filename) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let pos = position_to_char_index(params.text_document_position_params.position, ¤t_code);
|
||||
|
||||
// Let's iterate over the AST and find the node that contains the cursor.
|
||||
let Some(ast) = self.ast_map.get(&filename) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(value) = ast.get_value_for_position(pos) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(hover) = value.get_hover_value_for_position(pos, ¤t_code) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match hover {
|
||||
crate::ast::types::Hover::Function { name, range } => {
|
||||
// Get the docs for this function.
|
||||
let Some(completion) = self.stdlib_completions.get(&name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(docs) = &completion.documentation else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let docs = match docs {
|
||||
Documentation::String(docs) => docs,
|
||||
Documentation::MarkupContent(MarkupContent { value, .. }) => value,
|
||||
};
|
||||
|
||||
let Some(label_details) = &completion.label_details else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(Hover {
|
||||
contents: HoverContents::Markup(MarkupContent {
|
||||
kind: MarkupKind::Markdown,
|
||||
value: format!(
|
||||
"```{}{}```\n{}",
|
||||
name,
|
||||
label_details.detail.clone().unwrap_or_default(),
|
||||
docs
|
||||
),
|
||||
}),
|
||||
range: Some(range),
|
||||
}))
|
||||
}
|
||||
crate::ast::types::Hover::Signature { .. } => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn completion(&self, params: CompletionParams) -> RpcResult<Option<CompletionResponse>> {
|
||||
let mut completions = vec![CompletionItem {
|
||||
label: PIPE_OPERATOR.to_string(),
|
||||
label_details: None,
|
||||
kind: Some(CompletionItemKind::OPERATOR),
|
||||
detail: Some("A pipe operator.".to_string()),
|
||||
documentation: Some(Documentation::MarkupContent(MarkupContent {
|
||||
kind: MarkupKind::Markdown,
|
||||
value: "A pipe operator.".to_string(),
|
||||
})),
|
||||
deprecated: Some(false),
|
||||
preselect: None,
|
||||
sort_text: None,
|
||||
filter_text: None,
|
||||
insert_text: Some("|> ".to_string()),
|
||||
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
|
||||
insert_text_mode: None,
|
||||
text_edit: None,
|
||||
additional_text_edits: None,
|
||||
command: None,
|
||||
commit_characters: None,
|
||||
data: None,
|
||||
tags: None,
|
||||
}];
|
||||
|
||||
completions.extend(self.stdlib_completions.values().cloned());
|
||||
|
||||
// Get our variables from our AST to include in our completions.
|
||||
completions.extend(
|
||||
self.completions_get_variables_from_ast(params.text_document_position.text_document.uri.as_ref())
|
||||
.await,
|
||||
);
|
||||
|
||||
Ok(Some(CompletionResponse::Array(completions)))
|
||||
}
|
||||
|
||||
async fn diagnostic(&self, params: DocumentDiagnosticParams) -> RpcResult<DocumentDiagnosticReportResult> {
|
||||
let filename = params.text_document.uri.to_string();
|
||||
|
||||
// Get the current diagnostics for this file.
|
||||
let Some(diagnostic) = self.diagnostics_map.get(&filename) else {
|
||||
// Send an empty report.
|
||||
return Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
|
||||
RelatedFullDocumentDiagnosticReport {
|
||||
related_documents: None,
|
||||
full_document_diagnostic_report: FullDocumentDiagnosticReport {
|
||||
result_id: None,
|
||||
items: vec![],
|
||||
},
|
||||
},
|
||||
)));
|
||||
};
|
||||
|
||||
Ok(DocumentDiagnosticReportResult::Report(diagnostic.clone()))
|
||||
}
|
||||
|
||||
async fn signature_help(&self, params: SignatureHelpParams) -> RpcResult<Option<SignatureHelp>> {
|
||||
let filename = params.text_document_position_params.text_document.uri.to_string();
|
||||
|
||||
let Some(current_code) = self.current_code_map.get(&filename) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let pos = position_to_char_index(params.text_document_position_params.position, ¤t_code);
|
||||
|
||||
// Let's iterate over the AST and find the node that contains the cursor.
|
||||
let Some(ast) = self.ast_map.get(&filename) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(value) = ast.get_value_for_position(pos) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(hover) = value.get_hover_value_for_position(pos, ¤t_code) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match hover {
|
||||
crate::ast::types::Hover::Function { name, range: _ } => {
|
||||
// Get the docs for this function.
|
||||
let Some(signature) = self.stdlib_signatures.get(&name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(signature.clone()))
|
||||
}
|
||||
crate::ast::types::Hover::Signature {
|
||||
name,
|
||||
parameter_index,
|
||||
range: _,
|
||||
} => {
|
||||
let Some(signature) = self.stdlib_signatures.get(&name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut signature = signature.clone();
|
||||
|
||||
signature.active_parameter = Some(parameter_index);
|
||||
|
||||
Ok(Some(signature.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn inlay_hint(&self, _params: InlayHintParams) -> RpcResult<Option<Vec<InlayHint>>> {
|
||||
// TODO: do this
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn semantic_tokens_full(&self, params: SemanticTokensParams) -> RpcResult<Option<SemanticTokensResult>> {
|
||||
let filename = params.text_document.uri.to_string();
|
||||
|
||||
let Some(semantic_tokens) = self.semantic_tokens_map.get(&filename) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
|
||||
result_id: None,
|
||||
data: semantic_tokens.clone(),
|
||||
})))
|
||||
}
|
||||
|
||||
async fn document_symbol(&self, params: DocumentSymbolParams) -> RpcResult<Option<DocumentSymbolResponse>> {
|
||||
let filename = params.text_document.uri.to_string();
|
||||
|
||||
let Some(symbols) = self.symbols_map.get(&filename) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(DocumentSymbolResponse::Nested(symbols.clone())))
|
||||
}
|
||||
|
||||
async fn formatting(&self, params: DocumentFormattingParams) -> RpcResult<Option<Vec<TextEdit>>> {
|
||||
let filename = params.text_document.uri.to_string();
|
||||
|
||||
let Some(current_code) = self.current_code_map.get(&filename) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Parse the ast.
|
||||
// I don't know if we need to do this again since it should be updated in the context.
|
||||
// But I figure better safe than sorry since this will write back out to the file.
|
||||
let tokens = crate::token::lexer(¤t_code);
|
||||
let parser = crate::parser::Parser::new(tokens);
|
||||
let Ok(ast) = parser.ast() else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Now recast it.
|
||||
let recast = ast.recast(
|
||||
&crate::ast::types::FormatOptions {
|
||||
tab_size: params.options.tab_size as usize,
|
||||
insert_final_newline: params.options.insert_final_newline.unwrap_or(false),
|
||||
use_tabs: !params.options.insert_spaces,
|
||||
},
|
||||
0,
|
||||
);
|
||||
let source_range = SourceRange([0, current_code.len() - 1]);
|
||||
let range = source_range.to_lsp_range(¤t_code);
|
||||
Ok(Some(vec![TextEdit {
|
||||
new_text: recast,
|
||||
range,
|
||||
}]))
|
||||
}
|
||||
|
||||
async fn rename(&self, params: RenameParams) -> RpcResult<Option<WorkspaceEdit>> {
|
||||
let filename = params.text_document_position.text_document.uri.to_string();
|
||||
|
||||
let Some(current_code) = self.current_code_map.get(&filename) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Parse the ast.
|
||||
// I don't know if we need to do this again since it should be updated in the context.
|
||||
// But I figure better safe than sorry since this will write back out to the file.
|
||||
let tokens = crate::token::lexer(¤t_code);
|
||||
let parser = crate::parser::Parser::new(tokens);
|
||||
let Ok(mut ast) = parser.ast() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Let's convert the position to a character index.
|
||||
let pos = position_to_char_index(params.text_document_position.position, ¤t_code);
|
||||
// Now let's perform the rename on the ast.
|
||||
ast.rename_symbol(¶ms.new_name, pos);
|
||||
// Now recast it.
|
||||
let recast = ast.recast(&Default::default(), 0);
|
||||
let source_range = SourceRange([0, current_code.len() - 1]);
|
||||
let range = source_range.to_lsp_range(¤t_code);
|
||||
Ok(Some(WorkspaceEdit {
|
||||
changes: Some(HashMap::from([(
|
||||
params.text_document_position.text_document.uri,
|
||||
vec![TextEdit {
|
||||
new_text: recast,
|
||||
range,
|
||||
}],
|
||||
)])),
|
||||
document_changes: None,
|
||||
change_annotations: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get completions from our stdlib.
|
||||
pub fn get_completions_from_stdlib(stdlib: &crate::std::StdLib) -> Result<HashMap<String, CompletionItem>> {
|
||||
let mut completions = HashMap::new();
|
||||
|
||||
for internal_fn in stdlib.fns.values() {
|
||||
completions.insert(internal_fn.name(), internal_fn.to_completion_item());
|
||||
}
|
||||
|
||||
let variable_kinds = VariableKind::to_completion_items()?;
|
||||
for variable_kind in variable_kinds {
|
||||
completions.insert(variable_kind.label.clone(), variable_kind);
|
||||
}
|
||||
|
||||
Ok(completions)
|
||||
}
|
||||
|
||||
/// Get signatures from our stdlib.
|
||||
pub fn get_signatures_from_stdlib(stdlib: &crate::std::StdLib) -> Result<HashMap<String, SignatureHelp>> {
|
||||
let mut signatures = HashMap::new();
|
||||
|
||||
for internal_fn in stdlib.fns.values() {
|
||||
signatures.insert(internal_fn.name(), internal_fn.to_signature_help());
|
||||
}
|
||||
|
||||
let show = SignatureHelp {
|
||||
signatures: vec![SignatureInformation {
|
||||
label: "show".to_string(),
|
||||
documentation: Some(Documentation::MarkupContent(MarkupContent {
|
||||
kind: MarkupKind::PlainText,
|
||||
value: "Show a model.".to_string(),
|
||||
})),
|
||||
parameters: Some(vec![ParameterInformation {
|
||||
label: ParameterLabel::Simple("sg: SketchGroup".to_string()),
|
||||
documentation: Some(Documentation::MarkupContent(MarkupContent {
|
||||
kind: MarkupKind::PlainText,
|
||||
value: "A sketch group.".to_string(),
|
||||
})),
|
||||
}]),
|
||||
active_parameter: None,
|
||||
}],
|
||||
active_signature: Some(0),
|
||||
active_parameter: None,
|
||||
};
|
||||
signatures.insert("show".to_string(), show);
|
||||
|
||||
Ok(signatures)
|
||||
}
|
||||
|
||||
/// Convert a position to a character index from the start of the file.
|
||||
fn position_to_char_index(position: Position, code: &str) -> usize {
|
||||
// Get the character position from the start of the file.
|
||||
let mut char_position = 0;
|
||||
for (index, line) in code.lines().enumerate() {
|
||||
if index == position.line as usize {
|
||||
char_position += position.character as usize;
|
||||
break;
|
||||
} else {
|
||||
char_position += line.len() + 1;
|
||||
}
|
||||
}
|
||||
|
||||
char_position
|
||||
}
|
@ -1,672 +1,6 @@
|
||||
//! Functions for the `kcl` lsp server.
|
||||
//! The servers that power the text editor.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
#[cfg(feature = "cli")]
|
||||
use clap::Parser;
|
||||
use dashmap::DashMap;
|
||||
use tower_lsp::{jsonrpc::Result as RpcResult, lsp_types::*, Client, LanguageServer};
|
||||
|
||||
use crate::{ast::types::VariableKind, executor::SourceRange, parser::PIPE_OPERATOR};
|
||||
|
||||
/// A subcommand for running the server.
|
||||
#[derive(Clone, Debug)]
|
||||
#[cfg_attr(feature = "cli", derive(Parser))]
|
||||
pub struct Server {
|
||||
/// Port that the server should listen
|
||||
#[cfg_attr(feature = "cli", clap(long, default_value = "8080"))]
|
||||
pub socket: i32,
|
||||
|
||||
/// Listen over stdin and stdout instead of a tcp socket.
|
||||
#[cfg_attr(feature = "cli", clap(short, long, default_value = "false"))]
|
||||
pub stdio: bool,
|
||||
}
|
||||
|
||||
/// The lsp server backend.
|
||||
pub struct Backend {
|
||||
/// The client for the backend.
|
||||
pub client: Client,
|
||||
/// The stdlib completions for the language.
|
||||
pub stdlib_completions: HashMap<String, CompletionItem>,
|
||||
/// The stdlib signatures for the language.
|
||||
pub stdlib_signatures: HashMap<String, SignatureHelp>,
|
||||
/// The types of tokens the server supports.
|
||||
pub token_types: Vec<SemanticTokenType>,
|
||||
/// Token maps.
|
||||
pub token_map: DashMap<String, Vec<crate::token::Token>>,
|
||||
/// AST maps.
|
||||
pub ast_map: DashMap<String, crate::ast::types::Program>,
|
||||
/// Current code.
|
||||
pub current_code_map: DashMap<String, String>,
|
||||
/// Diagnostics.
|
||||
pub diagnostics_map: DashMap<String, DocumentDiagnosticReport>,
|
||||
/// Symbols map.
|
||||
pub symbols_map: DashMap<String, Vec<DocumentSymbol>>,
|
||||
/// Semantic tokens map.
|
||||
pub semantic_tokens_map: DashMap<String, Vec<SemanticToken>>,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
fn get_semantic_token_type_index(&self, token_type: SemanticTokenType) -> Option<usize> {
|
||||
self.token_types.iter().position(|x| *x == token_type)
|
||||
}
|
||||
|
||||
async fn on_change(&self, params: TextDocumentItem) {
|
||||
// Lets update the tokens.
|
||||
self.current_code_map
|
||||
.insert(params.uri.to_string(), params.text.clone());
|
||||
let tokens = crate::token::lexer(¶ms.text);
|
||||
self.token_map.insert(params.uri.to_string(), tokens.clone());
|
||||
|
||||
// Update the semantic tokens map.
|
||||
let mut semantic_tokens = vec![];
|
||||
let mut last_position = Position::new(0, 0);
|
||||
for token in &tokens {
|
||||
let Ok(mut token_type) = SemanticTokenType::try_from(token.token_type) else {
|
||||
// We continue here because not all tokens can be converted this way, we will get
|
||||
// the rest from the ast.
|
||||
continue;
|
||||
};
|
||||
|
||||
if token.token_type == crate::token::TokenType::Word && self.stdlib_completions.contains_key(&token.value) {
|
||||
// This is a stdlib function.
|
||||
token_type = SemanticTokenType::FUNCTION;
|
||||
}
|
||||
|
||||
let token_type_index = match self.get_semantic_token_type_index(token_type.clone()) {
|
||||
Some(index) => index,
|
||||
// This is actually bad this should not fail.
|
||||
// TODO: ensure we never get here.
|
||||
None => {
|
||||
self.client
|
||||
.log_message(
|
||||
MessageType::INFO,
|
||||
format!("token type `{:?}` not accounted for", token_type),
|
||||
)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let source_range: SourceRange = token.clone().into();
|
||||
let position = source_range.start_to_lsp_position(¶ms.text);
|
||||
|
||||
let semantic_token = SemanticToken {
|
||||
delta_line: position.line - last_position.line,
|
||||
delta_start: if position.line != last_position.line {
|
||||
position.character
|
||||
} else {
|
||||
position.character - last_position.character
|
||||
},
|
||||
length: token.value.len() as u32,
|
||||
token_type: token_type_index as u32,
|
||||
token_modifiers_bitset: 0,
|
||||
};
|
||||
|
||||
semantic_tokens.push(semantic_token);
|
||||
|
||||
last_position = position;
|
||||
}
|
||||
self.semantic_tokens_map.insert(params.uri.to_string(), semantic_tokens);
|
||||
|
||||
// Lets update the ast.
|
||||
let parser = crate::parser::Parser::new(tokens);
|
||||
let result = parser.ast();
|
||||
let ast = match result {
|
||||
Ok(ast) => ast,
|
||||
Err(e) => {
|
||||
let diagnostic = e.to_lsp_diagnostic(¶ms.text);
|
||||
// We got errors, update the diagnostics.
|
||||
self.diagnostics_map.insert(
|
||||
params.uri.to_string(),
|
||||
DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
|
||||
related_documents: None,
|
||||
full_document_diagnostic_report: FullDocumentDiagnosticReport {
|
||||
result_id: None,
|
||||
items: vec![diagnostic.clone()],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Publish the diagnostic.
|
||||
// If the client supports it.
|
||||
self.client
|
||||
.publish_diagnostics(params.uri, vec![diagnostic], None)
|
||||
.await;
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Update the symbols map.
|
||||
self.symbols_map
|
||||
.insert(params.uri.to_string(), ast.get_lsp_symbols(¶ms.text));
|
||||
|
||||
self.ast_map.insert(params.uri.to_string(), ast);
|
||||
// Lets update the diagnostics, since we got no errors.
|
||||
self.diagnostics_map.insert(
|
||||
params.uri.to_string(),
|
||||
DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
|
||||
related_documents: None,
|
||||
full_document_diagnostic_report: FullDocumentDiagnosticReport {
|
||||
result_id: None,
|
||||
items: vec![],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Publish the diagnostic, we reset it here so the client knows the code compiles now.
|
||||
// If the client supports it.
|
||||
self.client.publish_diagnostics(params.uri.clone(), vec![], None).await;
|
||||
}
|
||||
|
||||
async fn completions_get_variables_from_ast(&self, file_name: &str) -> Vec<CompletionItem> {
|
||||
let mut completions = vec![];
|
||||
|
||||
let ast = match self.ast_map.get(file_name) {
|
||||
Some(ast) => ast,
|
||||
None => return completions,
|
||||
};
|
||||
|
||||
for item in &ast.body {
|
||||
match item {
|
||||
crate::ast::types::BodyItem::ExpressionStatement(_) => continue,
|
||||
crate::ast::types::BodyItem::ReturnStatement(_) => continue,
|
||||
crate::ast::types::BodyItem::VariableDeclaration(variable) => {
|
||||
// We only want to complete variables.
|
||||
for declaration in &variable.declarations {
|
||||
completions.push(CompletionItem {
|
||||
label: declaration.id.name.to_string(),
|
||||
label_details: None,
|
||||
kind: Some(match variable.kind {
|
||||
crate::ast::types::VariableKind::Let => CompletionItemKind::VARIABLE,
|
||||
crate::ast::types::VariableKind::Const => CompletionItemKind::CONSTANT,
|
||||
crate::ast::types::VariableKind::Var => CompletionItemKind::VARIABLE,
|
||||
crate::ast::types::VariableKind::Fn => CompletionItemKind::FUNCTION,
|
||||
}),
|
||||
detail: Some(variable.kind.to_string()),
|
||||
documentation: None,
|
||||
deprecated: None,
|
||||
preselect: None,
|
||||
sort_text: None,
|
||||
filter_text: None,
|
||||
insert_text: None,
|
||||
insert_text_format: None,
|
||||
insert_text_mode: None,
|
||||
text_edit: None,
|
||||
additional_text_edits: None,
|
||||
command: None,
|
||||
commit_characters: None,
|
||||
data: None,
|
||||
tags: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
completions
|
||||
}
|
||||
}
|
||||
|
||||
#[tower_lsp::async_trait]
|
||||
impl LanguageServer for Backend {
|
||||
async fn initialize(&self, params: InitializeParams) -> RpcResult<InitializeResult> {
|
||||
self.client
|
||||
.log_message(MessageType::INFO, format!("initialize: {:?}", params))
|
||||
.await;
|
||||
|
||||
Ok(InitializeResult {
|
||||
capabilities: ServerCapabilities {
|
||||
completion_provider: Some(CompletionOptions {
|
||||
resolve_provider: Some(false),
|
||||
trigger_characters: Some(vec![".".to_string()]),
|
||||
work_done_progress_options: Default::default(),
|
||||
all_commit_characters: None,
|
||||
..Default::default()
|
||||
}),
|
||||
diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
|
||||
..Default::default()
|
||||
})),
|
||||
document_formatting_provider: Some(OneOf::Left(true)),
|
||||
document_symbol_provider: Some(OneOf::Left(true)),
|
||||
hover_provider: Some(HoverProviderCapability::Simple(true)),
|
||||
inlay_hint_provider: Some(OneOf::Left(true)),
|
||||
rename_provider: Some(OneOf::Left(true)),
|
||||
semantic_tokens_provider: Some(SemanticTokensServerCapabilities::SemanticTokensRegistrationOptions(
|
||||
SemanticTokensRegistrationOptions {
|
||||
text_document_registration_options: {
|
||||
TextDocumentRegistrationOptions {
|
||||
document_selector: Some(vec![DocumentFilter {
|
||||
language: Some("kcl".to_string()),
|
||||
scheme: Some("file".to_string()),
|
||||
pattern: None,
|
||||
}]),
|
||||
}
|
||||
},
|
||||
semantic_tokens_options: SemanticTokensOptions {
|
||||
work_done_progress_options: WorkDoneProgressOptions::default(),
|
||||
legend: SemanticTokensLegend {
|
||||
token_types: self.token_types.clone(),
|
||||
token_modifiers: vec![],
|
||||
},
|
||||
range: Some(false),
|
||||
full: Some(SemanticTokensFullOptions::Bool(true)),
|
||||
},
|
||||
static_registration_options: StaticRegistrationOptions::default(),
|
||||
},
|
||||
)),
|
||||
signature_help_provider: Some(SignatureHelpOptions {
|
||||
trigger_characters: None,
|
||||
retrigger_characters: None,
|
||||
..Default::default()
|
||||
}),
|
||||
text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
|
||||
open_close: Some(true),
|
||||
change: Some(TextDocumentSyncKind::FULL),
|
||||
..Default::default()
|
||||
})),
|
||||
workspace: Some(WorkspaceServerCapabilities {
|
||||
workspace_folders: Some(WorkspaceFoldersServerCapabilities {
|
||||
supported: Some(true),
|
||||
change_notifications: Some(OneOf::Left(true)),
|
||||
}),
|
||||
file_operations: None,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
async fn initialized(&self, params: InitializedParams) {
|
||||
self.client
|
||||
.log_message(MessageType::INFO, format!("initialized: {:?}", params))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> RpcResult<()> {
|
||||
self.client.log_message(MessageType::INFO, "shutdown".to_string()).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn did_change_workspace_folders(&self, _: DidChangeWorkspaceFoldersParams) {
|
||||
self.client
|
||||
.log_message(MessageType::INFO, "workspace folders changed!")
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn did_change_configuration(&self, _: DidChangeConfigurationParams) {
|
||||
self.client
|
||||
.log_message(MessageType::INFO, "configuration changed!")
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn did_change_watched_files(&self, _: DidChangeWatchedFilesParams) {
|
||||
self.client
|
||||
.log_message(MessageType::INFO, "watched files have changed!")
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn did_open(&self, params: DidOpenTextDocumentParams) {
|
||||
self.on_change(TextDocumentItem {
|
||||
uri: params.text_document.uri,
|
||||
text: params.text_document.text,
|
||||
version: params.text_document.version,
|
||||
language_id: params.text_document.language_id,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn did_change(&self, mut params: DidChangeTextDocumentParams) {
|
||||
self.on_change(TextDocumentItem {
|
||||
uri: params.text_document.uri,
|
||||
text: std::mem::take(&mut params.content_changes[0].text),
|
||||
version: params.text_document.version,
|
||||
language_id: Default::default(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn did_save(&self, params: DidSaveTextDocumentParams) {
|
||||
if let Some(text) = params.text {
|
||||
self.on_change(TextDocumentItem {
|
||||
uri: params.text_document.uri,
|
||||
text,
|
||||
version: Default::default(),
|
||||
language_id: Default::default(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
async fn did_close(&self, _: DidCloseTextDocumentParams) {
|
||||
self.client.log_message(MessageType::INFO, "file closed!").await;
|
||||
}
|
||||
|
||||
async fn hover(&self, params: HoverParams) -> RpcResult<Option<Hover>> {
|
||||
let filename = params.text_document_position_params.text_document.uri.to_string();
|
||||
|
||||
let Some(current_code) = self.current_code_map.get(&filename) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let pos = position_to_char_index(params.text_document_position_params.position, ¤t_code);
|
||||
|
||||
// Let's iterate over the AST and find the node that contains the cursor.
|
||||
let Some(ast) = self.ast_map.get(&filename) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(value) = ast.get_value_for_position(pos) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(hover) = value.get_hover_value_for_position(pos, ¤t_code) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match hover {
|
||||
crate::ast::types::Hover::Function { name, range } => {
|
||||
// Get the docs for this function.
|
||||
let Some(completion) = self.stdlib_completions.get(&name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(docs) = &completion.documentation else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let docs = match docs {
|
||||
Documentation::String(docs) => docs,
|
||||
Documentation::MarkupContent(MarkupContent { value, .. }) => value,
|
||||
};
|
||||
|
||||
let Some(label_details) = &completion.label_details else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(Hover {
|
||||
contents: HoverContents::Markup(MarkupContent {
|
||||
kind: MarkupKind::Markdown,
|
||||
value: format!(
|
||||
"```{}{}```\n{}",
|
||||
name,
|
||||
label_details.detail.clone().unwrap_or_default(),
|
||||
docs
|
||||
),
|
||||
}),
|
||||
range: Some(range),
|
||||
}))
|
||||
}
|
||||
crate::ast::types::Hover::Signature { .. } => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn completion(&self, params: CompletionParams) -> RpcResult<Option<CompletionResponse>> {
|
||||
let mut completions = vec![CompletionItem {
|
||||
label: PIPE_OPERATOR.to_string(),
|
||||
label_details: None,
|
||||
kind: Some(CompletionItemKind::OPERATOR),
|
||||
detail: Some("A pipe operator.".to_string()),
|
||||
documentation: Some(Documentation::MarkupContent(MarkupContent {
|
||||
kind: MarkupKind::Markdown,
|
||||
value: "A pipe operator.".to_string(),
|
||||
})),
|
||||
deprecated: Some(false),
|
||||
preselect: None,
|
||||
sort_text: None,
|
||||
filter_text: None,
|
||||
insert_text: Some("|> ".to_string()),
|
||||
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
|
||||
insert_text_mode: None,
|
||||
text_edit: None,
|
||||
additional_text_edits: None,
|
||||
command: None,
|
||||
commit_characters: None,
|
||||
data: None,
|
||||
tags: None,
|
||||
}];
|
||||
|
||||
completions.extend(self.stdlib_completions.values().cloned());
|
||||
|
||||
// Get our variables from our AST to include in our completions.
|
||||
completions.extend(
|
||||
self.completions_get_variables_from_ast(params.text_document_position.text_document.uri.as_ref())
|
||||
.await,
|
||||
);
|
||||
|
||||
Ok(Some(CompletionResponse::Array(completions)))
|
||||
}
|
||||
|
||||
async fn diagnostic(&self, params: DocumentDiagnosticParams) -> RpcResult<DocumentDiagnosticReportResult> {
|
||||
let filename = params.text_document.uri.to_string();
|
||||
|
||||
// Get the current diagnostics for this file.
|
||||
let Some(diagnostic) = self.diagnostics_map.get(&filename) else {
|
||||
// Send an empty report.
|
||||
return Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
|
||||
RelatedFullDocumentDiagnosticReport {
|
||||
related_documents: None,
|
||||
full_document_diagnostic_report: FullDocumentDiagnosticReport {
|
||||
result_id: None,
|
||||
items: vec![],
|
||||
},
|
||||
},
|
||||
)));
|
||||
};
|
||||
|
||||
Ok(DocumentDiagnosticReportResult::Report(diagnostic.clone()))
|
||||
}
|
||||
|
||||
async fn signature_help(&self, params: SignatureHelpParams) -> RpcResult<Option<SignatureHelp>> {
|
||||
let filename = params.text_document_position_params.text_document.uri.to_string();
|
||||
|
||||
let Some(current_code) = self.current_code_map.get(&filename) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let pos = position_to_char_index(params.text_document_position_params.position, ¤t_code);
|
||||
|
||||
// Let's iterate over the AST and find the node that contains the cursor.
|
||||
let Some(ast) = self.ast_map.get(&filename) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(value) = ast.get_value_for_position(pos) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(hover) = value.get_hover_value_for_position(pos, ¤t_code) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match hover {
|
||||
crate::ast::types::Hover::Function { name, range: _ } => {
|
||||
// Get the docs for this function.
|
||||
let Some(signature) = self.stdlib_signatures.get(&name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(signature.clone()))
|
||||
}
|
||||
crate::ast::types::Hover::Signature {
|
||||
name,
|
||||
parameter_index,
|
||||
range: _,
|
||||
} => {
|
||||
let Some(signature) = self.stdlib_signatures.get(&name) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut signature = signature.clone();
|
||||
|
||||
signature.active_parameter = Some(parameter_index);
|
||||
|
||||
Ok(Some(signature.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn inlay_hint(&self, _params: InlayHintParams) -> RpcResult<Option<Vec<InlayHint>>> {
|
||||
// TODO: do this
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn semantic_tokens_full(&self, params: SemanticTokensParams) -> RpcResult<Option<SemanticTokensResult>> {
|
||||
let filename = params.text_document.uri.to_string();
|
||||
|
||||
let Some(semantic_tokens) = self.semantic_tokens_map.get(&filename) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
|
||||
result_id: None,
|
||||
data: semantic_tokens.clone(),
|
||||
})))
|
||||
}
|
||||
|
||||
async fn document_symbol(&self, params: DocumentSymbolParams) -> RpcResult<Option<DocumentSymbolResponse>> {
|
||||
let filename = params.text_document.uri.to_string();
|
||||
|
||||
let Some(symbols) = self.symbols_map.get(&filename) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(DocumentSymbolResponse::Nested(symbols.clone())))
|
||||
}
|
||||
|
||||
async fn formatting(&self, params: DocumentFormattingParams) -> RpcResult<Option<Vec<TextEdit>>> {
|
||||
let filename = params.text_document.uri.to_string();
|
||||
|
||||
let Some(current_code) = self.current_code_map.get(&filename) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Parse the ast.
|
||||
// I don't know if we need to do this again since it should be updated in the context.
|
||||
// But I figure better safe than sorry since this will write back out to the file.
|
||||
let tokens = crate::token::lexer(¤t_code);
|
||||
let parser = crate::parser::Parser::new(tokens);
|
||||
let Ok(ast) = parser.ast() else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Now recast it.
|
||||
let recast = ast.recast(
|
||||
&crate::ast::types::FormatOptions {
|
||||
tab_size: params.options.tab_size as usize,
|
||||
insert_final_newline: params.options.insert_final_newline.unwrap_or(false),
|
||||
use_tabs: !params.options.insert_spaces,
|
||||
},
|
||||
0,
|
||||
);
|
||||
let source_range = SourceRange([0, current_code.len() - 1]);
|
||||
let range = source_range.to_lsp_range(¤t_code);
|
||||
Ok(Some(vec![TextEdit {
|
||||
new_text: recast,
|
||||
range,
|
||||
}]))
|
||||
}
|
||||
|
||||
async fn rename(&self, params: RenameParams) -> RpcResult<Option<WorkspaceEdit>> {
|
||||
let filename = params.text_document_position.text_document.uri.to_string();
|
||||
|
||||
let Some(current_code) = self.current_code_map.get(&filename) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Parse the ast.
|
||||
// I don't know if we need to do this again since it should be updated in the context.
|
||||
// But I figure better safe than sorry since this will write back out to the file.
|
||||
let tokens = crate::token::lexer(¤t_code);
|
||||
let parser = crate::parser::Parser::new(tokens);
|
||||
let Ok(mut ast) = parser.ast() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Let's convert the position to a character index.
|
||||
let pos = position_to_char_index(params.text_document_position.position, ¤t_code);
|
||||
// Now let's perform the rename on the ast.
|
||||
ast.rename_symbol(¶ms.new_name, pos);
|
||||
// Now recast it.
|
||||
let recast = ast.recast(&Default::default(), 0);
|
||||
let source_range = SourceRange([0, current_code.len() - 1]);
|
||||
let range = source_range.to_lsp_range(¤t_code);
|
||||
Ok(Some(WorkspaceEdit {
|
||||
changes: Some(HashMap::from([(
|
||||
params.text_document_position.text_document.uri,
|
||||
vec![TextEdit {
|
||||
new_text: recast,
|
||||
range,
|
||||
}],
|
||||
)])),
|
||||
document_changes: None,
|
||||
change_annotations: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get completions from our stdlib.
|
||||
pub fn get_completions_from_stdlib(stdlib: &crate::std::StdLib) -> Result<HashMap<String, CompletionItem>> {
|
||||
let mut completions = HashMap::new();
|
||||
|
||||
for internal_fn in stdlib.fns.values() {
|
||||
completions.insert(internal_fn.name(), internal_fn.to_completion_item());
|
||||
}
|
||||
|
||||
let variable_kinds = VariableKind::to_completion_items()?;
|
||||
for variable_kind in variable_kinds {
|
||||
completions.insert(variable_kind.label.clone(), variable_kind);
|
||||
}
|
||||
|
||||
Ok(completions)
|
||||
}
|
||||
|
||||
/// Get signatures from our stdlib.
|
||||
pub fn get_signatures_from_stdlib(stdlib: &crate::std::StdLib) -> Result<HashMap<String, SignatureHelp>> {
|
||||
let mut signatures = HashMap::new();
|
||||
|
||||
for internal_fn in stdlib.fns.values() {
|
||||
signatures.insert(internal_fn.name(), internal_fn.to_signature_help());
|
||||
}
|
||||
|
||||
let show = SignatureHelp {
|
||||
signatures: vec![SignatureInformation {
|
||||
label: "show".to_string(),
|
||||
documentation: Some(Documentation::MarkupContent(MarkupContent {
|
||||
kind: MarkupKind::PlainText,
|
||||
value: "Show a model.".to_string(),
|
||||
})),
|
||||
parameters: Some(vec![ParameterInformation {
|
||||
label: ParameterLabel::Simple("sg: SketchGroup".to_string()),
|
||||
documentation: Some(Documentation::MarkupContent(MarkupContent {
|
||||
kind: MarkupKind::PlainText,
|
||||
value: "A sketch group.".to_string(),
|
||||
})),
|
||||
}]),
|
||||
active_parameter: None,
|
||||
}],
|
||||
active_signature: Some(0),
|
||||
active_parameter: None,
|
||||
};
|
||||
signatures.insert("show".to_string(), show);
|
||||
|
||||
Ok(signatures)
|
||||
}
|
||||
|
||||
/// Convert a position to a character index from the start of the file.
|
||||
fn position_to_char_index(position: Position, code: &str) -> usize {
|
||||
// Get the character position from the start of the file.
|
||||
let mut char_position = 0;
|
||||
for (index, line) in code.lines().enumerate() {
|
||||
if index == position.line as usize {
|
||||
char_position += position.character as usize;
|
||||
break;
|
||||
} else {
|
||||
char_position += line.len() + 1;
|
||||
}
|
||||
}
|
||||
|
||||
char_position
|
||||
}
|
||||
mod backend;
|
||||
pub mod copilot;
|
||||
pub mod lsp;
|
||||
mod util;
|
||||
|
33
src/wasm-lib/kcl/src/server/util.rs
Normal file
33
src/wasm-lib/kcl/src/server/util.rs
Normal file
@ -0,0 +1,33 @@
|
||||
//! Utility functions for working with ropes and positions.
|
||||
|
||||
use ropey::Rope;
|
||||
use tower_lsp::lsp_types::Position;
|
||||
|
||||
pub fn position_to_offset(position: Position, rope: &Rope) -> Option<usize> {
|
||||
Some(rope.try_line_to_char(position.line as usize).ok()? + position.character as usize)
|
||||
}
|
||||
|
||||
pub fn get_text_before(offset: usize, rope: &Rope) -> Option<String> {
|
||||
if offset == 0 {
|
||||
return Some("".to_string());
|
||||
}
|
||||
Some(rope.slice(0..offset).to_string())
|
||||
}
|
||||
|
||||
pub fn get_text_after(offset: usize, rope: &Rope) -> Option<String> {
|
||||
let end_idx = rope.len_chars();
|
||||
if offset == end_idx {
|
||||
return Some("".to_string());
|
||||
}
|
||||
Some(rope.slice(offset..end_idx).to_string())
|
||||
}
|
||||
|
||||
pub fn get_line_before(pos: Position, rope: &Rope) -> Option<String> {
|
||||
let char_offset = pos.character as usize;
|
||||
let offset = position_to_offset(pos, rope).unwrap();
|
||||
if char_offset == 0 {
|
||||
return Some("".to_string());
|
||||
}
|
||||
let line_start = offset - char_offset;
|
||||
Some(rope.slice(line_start..offset).to_string())
|
||||
}
|
@ -20,7 +20,6 @@ use parse_display::{Display, FromStr};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use self::{kcl_stdlib::KclStdLibFn, sketch::SketchOnFaceTag};
|
||||
use crate::{
|
||||
ast::types::parse_json_number_as_f64,
|
||||
docs::StdLibFn,
|
||||
@ -29,7 +28,10 @@ use crate::{
|
||||
executor::{
|
||||
ExecutorContext, ExtrudeGroup, Geometry, MemoryItem, Metadata, SketchGroup, SketchGroupSet, SourceRange,
|
||||
},
|
||||
std::sketch::SketchSurface,
|
||||
std::{
|
||||
kcl_stdlib::KclStdLibFn,
|
||||
sketch::{SketchOnFaceTag, SketchSurface},
|
||||
},
|
||||
};
|
||||
|
||||
pub type StdFn = fn(Args) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<MemoryItem, KclError>>>>;
|
||||
|
@ -918,6 +918,7 @@ async fn start_sketch_on_face(
|
||||
animated: false,
|
||||
ortho: false,
|
||||
entity_id: extrude_plane_id,
|
||||
adjust_camera: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
@ -1,13 +1,12 @@
|
||||
//! Wasm bindings for `kcl`.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use futures::stream::TryStreamExt;
|
||||
use gloo_utils::format::JsValueSerdeExt;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use kcl_lib::server::{get_completions_from_stdlib, get_signatures_from_stdlib, Backend};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use kcl_lib::std::utils;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use tower_lsp::{LspService, Server};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
@ -153,20 +152,20 @@ impl ServerConfig {
|
||||
// NOTE: input needs to be an AsyncIterator<Uint8Array, never, void> specifically
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[wasm_bindgen]
|
||||
pub async fn lsp_run(config: ServerConfig) -> Result<(), JsValue> {
|
||||
pub async fn kcl_lsp_run(config: ServerConfig) -> Result<(), JsValue> {
|
||||
let ServerConfig {
|
||||
into_server,
|
||||
from_server,
|
||||
} = config;
|
||||
|
||||
let stdlib = kcl_lib::std::StdLib::new();
|
||||
let stdlib_completions = get_completions_from_stdlib(&stdlib).map_err(|e| e.to_string())?;
|
||||
let stdlib_signatures = get_signatures_from_stdlib(&stdlib).map_err(|e| e.to_string())?;
|
||||
let stdlib_completions = kcl_lib::server::lsp::get_completions_from_stdlib(&stdlib).map_err(|e| e.to_string())?;
|
||||
let stdlib_signatures = kcl_lib::server::lsp::get_signatures_from_stdlib(&stdlib).map_err(|e| e.to_string())?;
|
||||
// We can unwrap here because we know the tokeniser is valid, since
|
||||
// we have a test for it.
|
||||
let token_types = kcl_lib::token::TokenType::all_semantic_token_types().unwrap();
|
||||
|
||||
let (service, socket) = LspService::new(|client| Backend {
|
||||
let (service, socket) = LspService::new(|client| kcl_lib::server::lsp::Backend {
|
||||
client,
|
||||
stdlib_completions,
|
||||
stdlib_signatures,
|
||||
@ -199,10 +198,63 @@ pub async fn lsp_run(config: ServerConfig) -> Result<(), JsValue> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the `copilot` lsp server.
|
||||
//
|
||||
// NOTE: we don't use web_sys::ReadableStream for input here because on the
|
||||
// browser side we need to use a ReadableByteStreamController to construct it
|
||||
// and so far only Chromium-based browsers support that functionality.
|
||||
|
||||
// NOTE: input needs to be an AsyncIterator<Uint8Array, never, void> specifically
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[wasm_bindgen]
|
||||
pub async fn copilot_lsp_run(config: ServerConfig, token: String) -> Result<(), JsValue> {
|
||||
let ServerConfig {
|
||||
into_server,
|
||||
from_server,
|
||||
} = config;
|
||||
|
||||
let (service, socket) = LspService::build(|client| kcl_lib::server::copilot::Backend {
|
||||
client,
|
||||
current_code_map: Default::default(),
|
||||
editor_info: Arc::new(RwLock::new(
|
||||
kcl_lib::server::copilot::types::CopilotEditorInfo::default(),
|
||||
)),
|
||||
cache: kcl_lib::server::copilot::cache::CopilotCache::new(),
|
||||
token,
|
||||
})
|
||||
.custom_method("setEditorInfo", kcl_lib::server::copilot::Backend::set_editor_info)
|
||||
.custom_method(
|
||||
"getCompletions",
|
||||
kcl_lib::server::copilot::Backend::get_completions_cycling,
|
||||
)
|
||||
.custom_method("notifyAccepted", kcl_lib::server::copilot::Backend::accept_completions)
|
||||
.custom_method("notifyRejected", kcl_lib::server::copilot::Backend::reject_completions)
|
||||
.finish();
|
||||
|
||||
let input = wasm_bindgen_futures::stream::JsStream::from(into_server);
|
||||
let input = input
|
||||
.map_ok(|value| {
|
||||
value
|
||||
.dyn_into::<js_sys::Uint8Array>()
|
||||
.expect("could not cast stream item to Uint8Array")
|
||||
.to_vec()
|
||||
})
|
||||
.map_err(|_err| std::io::Error::from(std::io::ErrorKind::Other))
|
||||
.into_async_read();
|
||||
|
||||
let output = wasm_bindgen::JsCast::unchecked_into::<wasm_streams::writable::sys::WritableStream>(from_server);
|
||||
let output = wasm_streams::WritableStream::from_raw(output);
|
||||
let output = output.try_into_async_write().map_err(|err| err.0)?;
|
||||
|
||||
Server::new(input, output, socket).serve(service).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[wasm_bindgen]
|
||||
pub fn is_points_ccw(points: &[f64]) -> i32 {
|
||||
utils::is_points_ccw_wasm(points)
|
||||
kcl_lib::std::utils::is_points_ccw_wasm(points)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@ -237,7 +289,7 @@ pub fn get_tangential_arc_to_info(
|
||||
tan_previous_point_y: f64,
|
||||
obtuse: bool,
|
||||
) -> TangentialArcInfoOutputWasm {
|
||||
let result = utils::get_tangential_arc_to_info(utils::TangentialArcInfoInput {
|
||||
let result = kcl_lib::std::utils::get_tangential_arc_to_info(kcl_lib::std::utils::TangentialArcInfoInput {
|
||||
arc_start_point: [arc_start_point_x, arc_start_point_y],
|
||||
arc_end_point: [arc_end_point_x, arc_end_point_y],
|
||||
tan_previous_point: [tan_previous_point_x, tan_previous_point_y],
|
||||
|
Reference in New Issue
Block a user