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:
Jess Frazelle
2024-02-15 13:56:31 -08:00
committed by GitHub
parent 2730b6d152
commit 15fae05659
26 changed files with 2162 additions and 748 deletions

View File

@ -1,3 +1,3 @@
[codespell] [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 skip: **/target,node_modules,build,**/Cargo.lock

View File

@ -7,8 +7,8 @@ use std::io::Read;
use anyhow::Result; use anyhow::Result;
use oauth2::TokenResponse; use oauth2::TokenResponse;
use tauri::{InvokeError, Manager};
use std::process::Command; use std::process::Command;
use tauri::{InvokeError, Manager};
const DEFAULT_HOST: &str = "https://api.kittycad.io"; const DEFAULT_HOST: &str = "https://api.kittycad.io";
/// This command returns the a json string parse from a toml file at the path. /// 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")] #[cfg(target_os = "macos")]
{ {
Command::new("open") Command::new("open").args(["-R", &path]).spawn().unwrap();
.args(["-R", &path])
.spawn()
.unwrap();
} }
} }

View File

@ -27,6 +27,7 @@ import { engineCommandManager } from '../lang/std/engineConnection'
import { kclManager, useKclContext } from 'lang/KclSingleton' import { kclManager, useKclContext } from 'lang/KclSingleton'
import { ModelingMachineEvent } from 'machines/modelingMachine' import { ModelingMachineEvent } from 'machines/modelingMachine'
import { sceneInfra } from 'clientSideScene/sceneInfra' import { sceneInfra } from 'clientSideScene/sceneInfra'
import { copilotBundle } from 'editor/copilot'
export const editorShortcutMeta = { export const editorShortcutMeta = {
formatCode: { formatCode: {
@ -46,15 +47,19 @@ export const TextEditor = ({
}) => { }) => {
const { const {
editorView, editorView,
isLSPServerReady, isKclLspServerReady,
isCopilotLspServerReady,
setEditorView, setEditorView,
setIsLSPServerReady, setIsKclLspServerReady,
setIsCopilotLspServerReady,
isShiftDown, isShiftDown,
} = useStore((s) => ({ } = useStore((s) => ({
editorView: s.editorView, editorView: s.editorView,
isLSPServerReady: s.isLSPServerReady, isKclLspServerReady: s.isKclLspServerReady,
isCopilotLspServerReady: s.isCopilotLspServerReady,
setEditorView: s.setEditorView, setEditorView: s.setEditorView,
setIsLSPServerReady: s.setIsLSPServerReady, setIsKclLspServerReady: s.setIsKclLspServerReady,
setIsCopilotLspServerReady: s.setIsCopilotLspServerReady,
isShiftDown: s.isShiftDown, isShiftDown: s.isShiftDown,
})) }))
const { code, errors } = useKclContext() const { code, errors } = useKclContext()
@ -66,7 +71,7 @@ export const TextEditor = ({
state, state,
} = useModelingContext() } = useModelingContext()
const { settings: { context: { textWrapping } = {} } = {} } = const { settings: { context: { textWrapping } = {} } = {}, auth } =
useSettingsAuthContext() useSettingsAuthContext()
const { commandBarSend } = useCommandsContext() const { commandBarSend } = useCommandsContext()
const { enable: convertEnabled, handleClick: convertCallback } = 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. // 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. // But the server happens async so we break this into two parts.
// Below is the client and server promise. // Below is the client and server promise.
const { lspClient } = useMemo(() => { const { lspClient: kclLspClient } = useMemo(() => {
const intoServer: IntoServer = new IntoServer() const intoServer: IntoServer = new IntoServer()
const fromServer: FromServer = FromServer.create() const fromServer: FromServer = FromServer.create()
const client = new Client(fromServer, intoServer) const client = new Client(fromServer, intoServer)
if (!TEST) { if (!TEST) {
Server.initialize(intoServer, fromServer).then((lspServer) => { Server.initialize(intoServer, fromServer).then((lspServer) => {
lspServer.start() lspServer.start('kcl')
setIsLSPServerReady(true) setIsKclLspServerReady(true)
}) })
} }
const lspClient = new LanguageServerClient({ client }) const lspClient = new LanguageServerClient({ client })
return { lspClient } return { lspClient }
}, [setIsLSPServerReady]) }, [setIsKclLspServerReady])
// Here we initialize the plugin which will start the client. // 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 // 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. // We do not want to restart the server, its just wasteful.
const kclLSP = useMemo(() => { const kclLSP = useMemo(() => {
let plugin = null let plugin = null
if (isLSPServerReady && !TEST) { if (isKclLspServerReady && !TEST) {
// Set up the lsp plugin. // Set up the lsp plugin.
const lsp = kclLanguage({ const lsp = kclLanguage({
// When we have more than one file, we'll need to change this. // When we have more than one file, we'll need to change this.
documentUri: `file:///we-just-have-one-file-for-now.kcl`, documentUri: `file:///we-just-have-one-file-for-now.kcl`,
workspaceFolders: null, workspaceFolders: null,
client: lspClient, client: kclLspClient,
}) })
plugin = lsp plugin = lsp
} }
return plugin 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 = React.useCallback((value: string, viewUpdate: ViewUpdate) => {
const onChange = (newCode: string) => { const onChange = (newCode: string) => {
@ -184,6 +227,7 @@ export const TextEditor = ({
] as Extension[] ] as Extension[]
if (kclLSP) extensions.push(kclLSP) if (kclLSP) extensions.push(kclLSP)
if (copilotLSP) extensions.push(copilotLSP)
// These extensions have proven to mess with vitest // These extensions have proven to mess with vitest
if (!TEST) { if (!TEST) {

503
src/editor/copilot/index.ts Normal file
View 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),
]

View File

@ -3,6 +3,67 @@ import Client from './client'
import { LanguageServerPlugin } from './plugin' import { LanguageServerPlugin } from './plugin'
import { SemanticToken, deserializeTokens } from './semantic_tokens' 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/ // https://microsoft.github.io/language-server-protocol/specifications/specification-current/
// Client to server then server to client // Client to server then server to client
@ -17,6 +78,9 @@ interface LSPRequestMap {
LSP.SemanticTokensParams, LSP.SemanticTokensParams,
LSP.SemanticTokens LSP.SemanticTokens
] ]
getCompletions: [CopilotGetCompletionsParams, CopilotGetCompletionsResult]
notifyAccepted: [CopilotAcceptCompletionParams, any]
notifyRejected: [CopilotRejectCompletionParams, any]
} }
// Client to server // Client to server
@ -55,6 +119,7 @@ export class LanguageServerClient {
private isUpdatingSemanticTokens: boolean = false private isUpdatingSemanticTokens: boolean = false
private semanticTokens: SemanticToken[] = [] private semanticTokens: SemanticToken[] = []
private queuedUids: string[] = []
constructor(options: LanguageServerClientOptions) { constructor(options: LanguageServerClientOptions) {
this.plugins = [] this.plugins = []
@ -62,6 +127,7 @@ export class LanguageServerClient {
this.ready = false this.ready = false
this.queuedUids = []
this.initializePromise = this.initialize() this.initializePromise = this.initialize()
} }
@ -145,6 +211,33 @@ export class LanguageServerClient {
return this.client.notify(method, params) 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) { private processNotification(notification: Notification) {
for (const plugin of this.plugins) plugin.processNotification(notification) for (const plugin of this.plugins) plugin.processNotification(notification)
} }

View File

@ -10,7 +10,7 @@ import {
NodeSet, NodeSet,
} from '@lezer/common' } from '@lezer/common'
import { LanguageServerClient } from '.' import { LanguageServerClient } from '.'
import { posToOffset } from './plugin' import { posToOffset } from 'editor/lsp/util'
import { SemanticToken } from './semantic_tokens' import { SemanticToken } from './semantic_tokens'
import { DocInput } from '@codemirror/language' import { DocInput } from '@codemirror/language'
import { tags, styleTags } from '@lezer/highlight' import { tags, styleTags } from '@lezer/highlight'

View File

@ -22,10 +22,10 @@ import type {
} from '@codemirror/autocomplete' } from '@codemirror/autocomplete'
import type { PublishDiagnosticsParams } from 'vscode-languageserver-protocol' import type { PublishDiagnosticsParams } from 'vscode-languageserver-protocol'
import type { ViewUpdate, PluginValue } from '@codemirror/view' import type { ViewUpdate, PluginValue } from '@codemirror/view'
import type { Text } from '@codemirror/state'
import type * as LSP from 'vscode-languageserver-protocol' import type * as LSP from 'vscode-languageserver-protocol'
import { LanguageServerClient, Notification } from '.' import { LanguageServerClient, Notification } from '.'
import { Marked } from '@ts-stack/markdown' import { Marked } from '@ts-stack/markdown'
import { offsetToPos, posToOffset } from 'editor/lsp/util'
const changesDelay = 500 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( function formatContents(
contents: LSP.MarkupContent | LSP.MarkedString | LSP.MarkedString[] contents: LSP.MarkupContent | LSP.MarkedString | LSP.MarkedString[]
): string { ): string {

View File

@ -1,6 +1,7 @@
import init, { import init, {
copilot_lsp_run,
InitOutput, InitOutput,
lsp_run, kcl_lsp_run,
ServerConfig, ServerConfig,
} from '../../wasm-lib/pkg/wasm_lib' } from '../../wasm-lib/pkg/wasm_lib'
import { FromServer, IntoServer } from './codec' import { FromServer, IntoServer } from './codec'
@ -29,8 +30,15 @@ export default class Server {
return server return server
} }
async start(): Promise<void> { async start(type_: 'kcl' | 'copilot', token?: string): Promise<void> {
const config = new ServerConfig(this.#intoServer, this.#fromServer) 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
View 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],
})

View File

@ -219,3 +219,8 @@ code {
word-break: normal; word-break: normal;
word-wrap: break-word; word-wrap: break-word;
} }
.cm-ghostText,
.cm-ghostText * {
color: rgb(120, 120, 120, 0.8) !important;
}

View File

@ -1653,7 +1653,7 @@ describe('parsing errors', () => {
let _theError let _theError
try { try {
const result = expect(parse(code)) let _ = expect(parse(code))
} catch (e) { } catch (e) {
_theError = e _theError = e
} }

View File

@ -66,8 +66,10 @@ export interface StoreState {
setMediaStream: (mediaStream: MediaStream) => void setMediaStream: (mediaStream: MediaStream) => void
isStreamReady: boolean isStreamReady: boolean
setIsStreamReady: (isStreamReady: boolean) => void setIsStreamReady: (isStreamReady: boolean) => void
isLSPServerReady: boolean isKclLspServerReady: boolean
setIsLSPServerReady: (isLSPServerReady: boolean) => void isCopilotLspServerReady: boolean
setIsKclLspServerReady: (isKclLspServerReady: boolean) => void
setIsCopilotLspServerReady: (isCopilotLspServerReady: boolean) => void
buttonDownInStream: number | undefined buttonDownInStream: number | undefined
setButtonDownInStream: (buttonDownInStream: number | undefined) => void setButtonDownInStream: (buttonDownInStream: number | undefined) => void
didDragInStream: boolean didDragInStream: boolean
@ -120,8 +122,12 @@ export const useStore = create<StoreState>()(
setMediaStream: (mediaStream) => set({ mediaStream }), setMediaStream: (mediaStream) => set({ mediaStream }),
isStreamReady: false, isStreamReady: false,
setIsStreamReady: (isStreamReady) => set({ isStreamReady }), setIsStreamReady: (isStreamReady) => set({ isStreamReady }),
isLSPServerReady: false, isKclLspServerReady: false,
setIsLSPServerReady: (isLSPServerReady) => set({ isLSPServerReady }), isCopilotLspServerReady: false,
setIsKclLspServerReady: (isKclLspServerReady) =>
set({ isKclLspServerReady }),
setIsCopilotLspServerReady: (isCopilotLspServerReady) =>
set({ isCopilotLspServerReady }),
buttonDownInStream: undefined, buttonDownInStream: undefined,
setButtonDownInStream: (buttonDownInStream) => { setButtonDownInStream: (buttonDownInStream) => {
set({ buttonDownInStream }) set({ buttonDownInStream })

View File

@ -522,9 +522,9 @@ dependencies = [
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.31" version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" checksum = "5bc015644b92d5890fab7489e49d21f879d5c990186827d42ec511919404f38b"
dependencies = [ dependencies = [
"android-tzdata", "android-tzdata",
"iana-time-zone", "iana-time-zone",
@ -532,7 +532,7 @@ dependencies = [
"num-traits 0.2.17", "num-traits 0.2.17",
"serde", "serde",
"wasm-bindgen", "wasm-bindgen",
"windows-targets 0.48.5", "windows-targets 0.52.0",
] ]
[[package]] [[package]]
@ -1902,6 +1902,7 @@ dependencies = [
"parse-display 0.9.0", "parse-display 0.9.0",
"pretty_assertions", "pretty_assertions",
"reqwest", "reqwest",
"ropey",
"schemars", "schemars",
"serde", "serde",
"serde_json", "serde_json",
@ -1931,9 +1932,9 @@ dependencies = [
[[package]] [[package]]
name = "kittycad" name = "kittycad"
version = "0.2.50" version = "0.2.53"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "441d8af990a3aab738f985328aa914a9eee5856131c4c6f1fd2bd61ba9d07f98" checksum = "a086e1a1bbddb3b38959c0f0ce6de6b3a3b7566e38e0b7d5fb101e91911beed4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -3130,10 +3131,12 @@ dependencies = [
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-rustls 0.24.1", "tokio-rustls 0.24.1",
"tokio-util",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams",
"web-sys", "web-sys",
"webpki-roots", "webpki-roots",
"winreg", "winreg",
@ -3257,6 +3260,16 @@ dependencies = [
"windows-sys 0.48.0", "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]] [[package]]
name = "rtcp" name = "rtcp"
version = "0.10.0" version = "0.10.0"
@ -3863,6 +3876,12 @@ dependencies = [
"der", "der",
] ]
[[package]]
name = "str_indices"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9557cb6521e8d009c51a8666f09356f4b817ba9ba0981a305bd86aee47bd35c"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.0" version = "0.11.0"

View File

@ -58,7 +58,7 @@ members = [
] ]
[workspace.dependencies] [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 = { git = "https://github.com/KittyCAD/modeling-api", branch = "main" }
kittycad-execution-plan-traits = "0.1.10" kittycad-execution-plan-traits = "0.1.10"
kittycad-modeling-session = { git = "https://github.com/KittyCAD/modeling-api", branch = "main" } kittycad-modeling-session = { git = "https://github.com/KittyCAD/modeling-api", branch = "main" }

View File

@ -19,12 +19,15 @@ dashmap = "5.5.3"
databake = { version = "0.1.7", features = ["derive"] } databake = { version = "0.1.7", features = ["derive"] }
#derive-docs = { version = "0.1.6" } #derive-docs = { version = "0.1.6" }
derive-docs = { path = "../derive-docs" } derive-docs = { path = "../derive-docs" }
futures = { version = "0.3.30" }
gltf-json = "1.4.0" gltf-json = "1.4.0"
kittycad = { workspace = true } kittycad = { workspace = true }
kittycad-execution-plan-macros = { workspace = true } kittycad-execution-plan-macros = { workspace = true }
kittycad-execution-plan-traits = { workspace = true } kittycad-execution-plan-traits = { workspace = true }
lazy_static = "1.4.0" lazy_static = "1.4.0"
parse-display = "0.9.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"] } schemars = { version = "0.8.16", features = ["impl_json_schema", "url", "uuid1"] }
serde = { version = "1.0.193", features = ["derive"] } serde = { version = "1.0.193", features = ["derive"] }
serde_json = "1.0.108" serde_json = "1.0.108"
@ -43,8 +46,6 @@ web-sys = { version = "0.3.68", features = ["console"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
approx = "0.5" approx = "0.5"
bson = { version = "2.9.0", features = ["uuid-1", "chrono"] } 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 = { version = "1.36.0", features = ["full"] }
tokio-tungstenite = { version = "0.21.0", features = ["rustls-tls-native-roots"] } tokio-tungstenite = { version = "0.21.0", features = ["rustls-tls-native-roots"] }
tower-lsp = { version = "0.20.0", features = ["proposed"] } tower-lsp = { version = "0.20.0", features = ["proposed"] }

View File

@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Map, Value as JValue}; use serde_json::{Map, Value as JValue};
use tower_lsp::lsp_types::{CompletionItem, CompletionItemKind, DocumentSymbol, Range as LspRange, SymbolKind}; 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::{ use crate::{
docs::StdLibFn, docs::StdLibFn,
errors::{KclError, KclErrorDetails}, errors::{KclError, KclErrorDetails},

View 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;
}
}

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

View 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(&params.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(&params)?;
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(&params)?;
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
}
}

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

View 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(&params.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(&params.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(&params.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(&params.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, &current_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, &current_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, &current_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, &current_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(&current_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(&current_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(&current_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, &current_code);
// Now let's perform the rename on the ast.
ast.rename_symbol(&params.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(&current_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
}

View File

@ -1,672 +1,6 @@
//! Functions for the `kcl` lsp server. //! The servers that power the text editor.
use std::collections::HashMap; mod backend;
pub mod copilot;
use anyhow::Result; pub mod lsp;
#[cfg(feature = "cli")] mod util;
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(&params.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(&params.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(&params.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(&params.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, &current_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, &current_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, &current_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, &current_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(&current_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(&current_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(&current_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, &current_code);
// Now let's perform the rename on the ast.
ast.rename_symbol(&params.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(&current_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
}

View 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())
}

View File

@ -20,7 +20,6 @@ use parse_display::{Display, FromStr};
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use self::{kcl_stdlib::KclStdLibFn, sketch::SketchOnFaceTag};
use crate::{ use crate::{
ast::types::parse_json_number_as_f64, ast::types::parse_json_number_as_f64,
docs::StdLibFn, docs::StdLibFn,
@ -29,7 +28,10 @@ use crate::{
executor::{ executor::{
ExecutorContext, ExtrudeGroup, Geometry, MemoryItem, Metadata, SketchGroup, SketchGroupSet, SourceRange, 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>>>>; pub type StdFn = fn(Args) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<MemoryItem, KclError>>>>;

View File

@ -918,6 +918,7 @@ async fn start_sketch_on_face(
animated: false, animated: false,
ortho: false, ortho: false,
entity_id: extrude_plane_id, entity_id: extrude_plane_id,
adjust_camera: false,
}, },
) )
.await?; .await?;

View File

@ -1,13 +1,12 @@
//! Wasm bindings for `kcl`. //! Wasm bindings for `kcl`.
#[cfg(target_arch = "wasm32")]
use std::sync::{Arc, RwLock};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use futures::stream::TryStreamExt; use futures::stream::TryStreamExt;
use gloo_utils::format::JsValueSerdeExt; use gloo_utils::format::JsValueSerdeExt;
#[cfg(target_arch = "wasm32")] #[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 tower_lsp::{LspService, Server};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
@ -153,20 +152,20 @@ impl ServerConfig {
// NOTE: input needs to be an AsyncIterator<Uint8Array, never, void> specifically // NOTE: input needs to be an AsyncIterator<Uint8Array, never, void> specifically
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
#[wasm_bindgen] #[wasm_bindgen]
pub async fn lsp_run(config: ServerConfig) -> Result<(), JsValue> { pub async fn kcl_lsp_run(config: ServerConfig) -> Result<(), JsValue> {
let ServerConfig { let ServerConfig {
into_server, into_server,
from_server, from_server,
} = config; } = config;
let stdlib = kcl_lib::std::StdLib::new(); let stdlib = kcl_lib::std::StdLib::new();
let stdlib_completions = get_completions_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 = get_signatures_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 can unwrap here because we know the tokeniser is valid, since
// we have a test for it. // we have a test for it.
let token_types = kcl_lib::token::TokenType::all_semantic_token_types().unwrap(); 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, client,
stdlib_completions, stdlib_completions,
stdlib_signatures, stdlib_signatures,
@ -199,10 +198,63 @@ pub async fn lsp_run(config: ServerConfig) -> Result<(), JsValue> {
Ok(()) 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")] #[cfg(target_arch = "wasm32")]
#[wasm_bindgen] #[wasm_bindgen]
pub fn is_points_ccw(points: &[f64]) -> i32 { 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")] #[cfg(target_arch = "wasm32")]
@ -237,7 +289,7 @@ pub fn get_tangential_arc_to_info(
tan_previous_point_y: f64, tan_previous_point_y: f64,
obtuse: bool, obtuse: bool,
) -> TangentialArcInfoOutputWasm { ) -> 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_start_point: [arc_start_point_x, arc_start_point_y],
arc_end_point: [arc_end_point_x, arc_end_point_y], arc_end_point: [arc_end_point_x, arc_end_point_y],
tan_previous_point: [tan_previous_point_x, tan_previous_point_y], tan_previous_point: [tan_previous_point_x, tan_previous_point_y],