├── .gitignore ├── LICENSE ├── README.md ├── client.go ├── langserver.go └── lsc.kak /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 14 | .glide/ 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kakoune-languageclient 2 | 3 | ## About 4 | 5 | Small implementation of a [language client](https://github.com/microsoft/language-server-protocol) for [Kakoune](https://github.com/mawww/kakoune). 6 | 7 | Uses a small Go binary to handle LSP JSONRPC translation to Kakoune commands. 8 | 9 | ## Design 10 | 11 | There are 3 parts to using LSP for Kakoune: Kakoune itself, a helper binary, and the language servers. Since the protocol communicates using JSON RPC 2.0 a helper binary is used manage the language servers and parsing the JSON. 12 | 13 | The helper binary interfaces with Kakoune by writing commands into a fifo file that is being read by the binary, the binary then translates the commands into LSP commands and talks to the language servers. Finally, the binary connects to the running Kakoune instance and executes Kakoue commands to render results/perform the requested actions. 14 | 15 | The setup process can be automated using a kak config file, the current setup is in lsc.kak. The language-client is currently not being started automatically due to stability issues, and requires pressing `0` to start. 16 | 17 | ## Development 18 | 19 | Currently the server is hardcoded to run only the [go language-server](https://github.com/sourcegraph/go-langserver) for testing. One of the next tasks will be to add configurations into the config file to handle language servers for more languages. 20 | 21 | The recommended setup for now is to symlink the lsc.kak file into your config files so all the source files are in one folder for committing. 22 | 23 | To aid in debugging the binary there is a manual launch method that can be used to display the output from the binary. The recommended method is to launch a Kakoune instance in one terminal, and then in another terminal launch the go binary, passing the session and client name (These values are shown in the bottom right of Kakoune by default). This way you can use print statements, etc. from the binary to help in debugging. 24 | 25 | Things are moving slowly, expect updates sporadically. 26 | 27 | ## Thanks 28 | 29 | Thanks to mawww for creating Kakoune and being so open about the development process. 30 | 31 | Inspired by https://github.com/danr/libkak/ where I got a few ideas from. 32 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "strconv" 12 | "strings" 13 | "syscall" 14 | ) 15 | 16 | type kakInstance struct { 17 | session, client, pipe string 18 | } 19 | 20 | type kakBuffer struct { 21 | file string // Don't actually open the file, we don't need to read it, the lsp does 22 | tmpFile *os.File // Where we can write the buffer for syncing. Allows reuseability 23 | language string 24 | lastEdit int 25 | lastSync int // So we know if we synced (i.e when this is 0) 26 | } 27 | 28 | type lspCommand struct { 29 | command string 30 | args []string 31 | } 32 | 33 | var servers map[string]*LangSrvr 34 | var buffers map[string]*kakBuffer 35 | 36 | func main() { 37 | if len(os.Args) < 3 { 38 | fmt.Println("Usage: kakoune-languageclient sessionId clientName") 39 | os.Exit(1) 40 | } 41 | session := os.Args[1] 42 | client := os.Args[2] 43 | tmpDir, _ := ioutil.TempDir("", "lsc") 44 | 45 | namedPipe := filepath.Join(tmpDir, session) 46 | syscall.Mkfifo(namedPipe, 0600) 47 | defer os.RemoveAll(namedPipe) 48 | 49 | instance := kakInstance{session, client, namedPipe} 50 | 51 | //hold the write end of the pipe so we dont get EOF 52 | fifo, _ := os.OpenFile(namedPipe, os.O_RDWR, os.ModeNamedPipe) 53 | 54 | //Create pipe first, then let Kakoune know about it 55 | instance.execCommand(fmt.Sprintf("decl str lsc_pipe %s", namedPipe)) 56 | 57 | servers = make(map[string]*LangSrvr) 58 | buffers = make(map[string]*kakBuffer) 59 | 60 | reader := bufio.NewReader(fifo) 61 | for { 62 | line, _, err := reader.ReadLine() 63 | if err == io.EOF { 64 | break 65 | } 66 | switch string(line) { 67 | case "Ping": 68 | instance.execCommand("echo -debug Pong\n") 69 | case "KakEnd": 70 | //TODO: shutdown servers 71 | //TODO: remove temp files 72 | //TODO: Try and make this a child of kak? Closing kak seems to close the spawned servers now... 73 | os.Exit(0) 74 | default: 75 | buf, cmd := tryParseCommand(string(line)) 76 | if buf != nil && cmd != nil { 77 | fmt.Printf("%v\n%v\n", buf, cmd) 78 | instance.execCommand(fmt.Sprintf("echo -debug \"%s %s\"", cmd.command, cmd.args)) 79 | handleCommand(&instance, buf, cmd) 80 | } 81 | } 82 | } 83 | } 84 | 85 | func handleCommand(instance *kakInstance, buf *kakBuffer, cmd *lspCommand) { 86 | server := getServer(buf.language) 87 | if buf.lastEdit > buf.lastSync { 88 | if buf.lastSync == 0 { 89 | tmpFile, err := ioutil.TempFile("", "lsc-tempBuf") 90 | if err != nil { 91 | fmt.Println("Cannot create tmp file to sync") 92 | return 93 | } 94 | buf.tmpFile = tmpFile 95 | } 96 | instance.execCommand(fmt.Sprintf("eval write -no-hooks %s", buf.tmpFile.Name())) 97 | server.HandleKak(buf, &lspCommand{command: "textDocument/sync"}) 98 | } 99 | kakCmd, err := server.HandleKak(buf, cmd) 100 | fmt.Println(kakCmd) //debug printing 101 | if err == nil { 102 | instance.execCommand(kakCmd) 103 | } 104 | } 105 | 106 | // Gets a server instance for a given filetype, launching and 107 | // initializing the connection as necessary 108 | func getServer(lang string) *LangSrvr { 109 | server, ok := servers[lang] 110 | if !ok { 111 | //Spawn a langserver for the language 112 | //TODO: read mapping of filetype to command from a config 113 | server = NewLangSrvr("go-langserver") 114 | servers[lang] = server 115 | server.Initialize() 116 | } 117 | return server 118 | } 119 | 120 | // string from kak formatted like 121 | // filetype:filename:editTimestamp:command:args1,args2,... 122 | func tryParseCommand(command string) (*kakBuffer, *lspCommand) { 123 | tokens := strings.Split(command, ":") 124 | if len(tokens) < 5 { 125 | return nil, nil 126 | } 127 | 128 | buf, ok := buffers[tokens[1]] 129 | opts := strings.Split(tokens[4], ",") 130 | cmd := &lspCommand{tokens[3], opts} 131 | if !ok { 132 | // Set dirty for new buffers since we don't know if they're pristine 133 | buf = &kakBuffer{language: tokens[0], file: tokens[1]} 134 | //TODO: Do I need a document/didOpen here before I can request a didChange? 135 | // If so then I need to track if didOpen was called (Maybe don't add to buffers? 136 | buffers[tokens[1]] = buf 137 | } 138 | 139 | timestamp, err := strconv.Atoi(tokens[2]) 140 | if err != nil { 141 | timestamp = 0 142 | } 143 | buf.lastEdit = timestamp 144 | 145 | return buf, cmd 146 | } 147 | 148 | func (inst *kakInstance) execCommand(command string) { 149 | cmd := exec.Command("kak", "-p", inst.session) 150 | in, err := cmd.StdinPipe() 151 | // Needs investigating: can you hold just one of these pipes and keep sending 152 | // commands instead of spawning a new process evry time? Could drop latencies 153 | if err != nil { 154 | fmt.Println("Failed to get the stdin pipe!") 155 | } 156 | //cmd.Stdout = os.Stdout 157 | //There is no Stdout for a -p 158 | cmd.Start() 159 | in.Write([]byte(fmt.Sprintf("eval -client %s \"%s\"", inst.client, command))) 160 | in.Close() 161 | cmd.Wait() 162 | } 163 | -------------------------------------------------------------------------------- /langserver.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "github.com/sourcegraph/jsonrpc2" 9 | "io" 10 | "io/ioutil" 11 | "os" 12 | "os/exec" 13 | "strconv" 14 | "strings" 15 | ) 16 | 17 | type Position struct { 18 | Line int 19 | Character int 20 | } 21 | 22 | type textRange struct { 23 | Start Position 24 | End Position 25 | } 26 | 27 | type ioReadWriteCloser struct { 28 | io.ReadCloser 29 | io.WriteCloser 30 | } 31 | 32 | type MarkedString markedString 33 | 34 | type markedString struct { 35 | Language string `json:"language,omitempty"` 36 | Value string `json:"value,omitempty"` 37 | Simple string `json:"simple,omitempty"` 38 | } 39 | 40 | func (m *MarkedString) UnmarshalJSON(data []byte) error { 41 | if d := strings.TrimSpace(string(data)); len(d) > 0 && d[0] == '"' { 42 | // Raw string 43 | var s string 44 | if err := json.Unmarshal(data, &s); err != nil { 45 | return err 46 | } 47 | m.Value = s 48 | return nil 49 | } 50 | // Language string 51 | ms := (*markedString)(m) 52 | return json.Unmarshal(data, ms) 53 | } 54 | 55 | //From https://github.com/natefinch/pie 56 | func (rw ioReadWriteCloser) Close() error { 57 | err := rw.ReadCloser.Close() 58 | if err := rw.WriteCloser.Close(); err != nil { 59 | return err 60 | } 61 | return err 62 | } 63 | 64 | func (rw ioReadWriteCloser) Write(buf []byte) (int, error) { 65 | fmt.Printf("--> %s\n", string(buf)) 66 | return rw.WriteCloser.Write(buf) 67 | } 68 | 69 | //Proxies reading in, so it may have to remove the header, which may or may not be present... 70 | func (rw ioReadWriteCloser) Read(p []byte) (int, error) { 71 | n, err := rw.ReadCloser.Read(p) 72 | fmt.Printf("<-- %s\n", string(p)) 73 | return n, err 74 | } 75 | 76 | type LangSrvr struct { 77 | conn *jsonrpc2.Conn 78 | handlers map[string]func(*kakBuffer, []string) string 79 | } 80 | 81 | func NewLangSrvr(command string) *LangSrvr { 82 | //Make a go-langserver to test 83 | server := exec.Command(command) 84 | fmt.Println("Starting langserver!") 85 | srvIn, err := server.StdinPipe() 86 | if err != nil { 87 | fmt.Printf("%s\n", err) 88 | } 89 | srvOut, err := server.StdoutPipe() 90 | if err != nil { 91 | fmt.Printf("%s\n", err) 92 | } 93 | serverConn := ioReadWriteCloser{srvOut, srvIn} 94 | err = server.Start() 95 | if err != nil { 96 | fmt.Printf("%s\n", err) 97 | } 98 | var lspRPC LangSrvr 99 | lspRPC.handlers = make(map[string](func(*kakBuffer, []string) string)) 100 | lspRPC.conn = jsonrpc2.NewConn(context.Background(), jsonrpc2.NewBufferedStream(serverConn, jsonrpc2.VSCodeObjectCodec{}), 101 | lspRPC) 102 | return &lspRPC 103 | } 104 | 105 | func (ls *LangSrvr) Initialize() { 106 | wd, _ := os.Getwd() 107 | fileUri := "file://" + wd 108 | params := map[string]interface{}{"processId": os.Getpid(), "rootUri": fileUri, "rootPath": wd, 109 | "capabilities": make(map[string]interface{})} 110 | //TODO: use capabilities to assign handlers 111 | ls.handlers["textDocument/sync"] = ls.tdSync 112 | ls.handlers["textDocument/hover"] = ls.tdHover 113 | ls.handlers["textDocument/signatureHelp"] = ls.tdSigHelp 114 | ls.execCommandSync("initialize", params, nil) 115 | ls.notify("initialized", nil) 116 | } 117 | 118 | func (ls LangSrvr) HandleKak(buf *kakBuffer, cmd *lspCommand) (string, error) { 119 | handler, ok := ls.handlers[cmd.command] 120 | if ok == false { 121 | return "", errors.New("Command does not exist") 122 | } 123 | return handler(buf, cmd.args), nil 124 | } 125 | 126 | // This function receives messages from the LS 127 | func (ls LangSrvr) Handle(c context.Context, conn *jsonrpc2.Conn, req *jsonrpc2.Request) { 128 | 129 | } 130 | 131 | func (ls LangSrvr) Shutdown() { 132 | ls.execCommandSync("shutdown", map[string]interface{}{}, nil) 133 | } 134 | 135 | func (ls LangSrvr) execCommandSync(command string, params map[string]interface{}, reply interface{}) error { 136 | err := ls.conn.Call(context.Background(), command, params, reply) 137 | if err != nil { 138 | fmt.Printf("Err1(): %q\n", err) 139 | return errors.New("RPC Error") 140 | } 141 | return nil 142 | } 143 | 144 | func (ls LangSrvr) notify(method string, args interface{}) { 145 | ls.conn.Notify(context.Background(), method, args) 146 | } 147 | 148 | //=============== Handlers ================================= 149 | 150 | // Provides syncing interface, however we can only sync the whole file because 151 | // Kakoune doesn't provide the changes since last save. The current method 152 | // involves telling Kakoune to write the buffer to a temp file and copying 153 | // the contents of the temp file. Eww... 154 | func (ls *LangSrvr) tdSync(buf *kakBuffer, params []string) string { 155 | contents, err := ioutil.ReadAll(buf.tmpFile) 156 | if err != nil { 157 | fmt.Println("Failed to read temp file for syncing") 158 | } 159 | if buf.lastSync == 0 { 160 | ls.notify("textDocument/didOpen", map[string]interface{}{ 161 | "textDocument": map[string]interface{}{ 162 | "uri": buf.file, 163 | "version": buf.lastEdit, 164 | "language": buf.language, 165 | "text": contents, 166 | }, 167 | }) 168 | } else { 169 | ls.notify("textDocument/didChange", map[string]interface{}{ 170 | "textDocument": map[string]interface{}{ 171 | "uri": buf.file, 172 | "version": buf.lastEdit, 173 | }, 174 | "contentChanges": []map[string]interface{}{ 175 | 0: {"text": contents}, 176 | }, 177 | }) 178 | } 179 | buf.lastSync = buf.lastEdit 180 | return "nop" //Oh look, a use for nop 181 | } 182 | 183 | // textDocument/hover requires syncing 184 | // buffile, line, charachter 185 | // textDocument/hover 186 | // params:{textDocument:URI, position:{line:,character:}} 187 | func (ls *LangSrvr) tdHover(buf *kakBuffer, params []string) string { 188 | fmt.Printf("hover: %s\n", params) 189 | uri := "file://" + buf.file 190 | line, _ := strconv.Atoi(params[0]) 191 | character, _ := strconv.Atoi(params[1]) 192 | paramMap := map[string]interface{}{ 193 | "textDocument": map[string]string{"uri": uri}, 194 | "position": map[string]interface{}{ 195 | "line": line - 1, 196 | "character": character - 1, 197 | }, 198 | } 199 | reply := struct { 200 | Docs []MarkedString `json:"contents"` 201 | Ranges textRange `json:"range,omitempty"` 202 | }{} 203 | err := ls.execCommandSync("textDocument/hover", paramMap, &reply) 204 | if err != nil { 205 | return "echo 'Command Failed'" 206 | } 207 | fmt.Println(reply) 208 | return fmt.Sprintf("info -placement below -anchor %s.%s '%s'", params[0], params[1], reply.Docs[0].Value) 209 | } 210 | 211 | // textDocument/signatureHelp requires syncing 212 | // params:{textDocument:URI, position:{line:#,character:#}} 213 | func (ls LangSrvr) tdSigHelp(buf *kakBuffer, params []string) string { 214 | fmt.Printf("sigHelp: %s\n", params) 215 | 216 | uri := "file://" + buf.file 217 | line, _ := strconv.Atoi(params[0]) 218 | character, _ := strconv.Atoi(params[1]) 219 | paramMap := map[string]interface{}{ 220 | "textDocument": map[string]string{"uri": uri}, 221 | "position": map[string]interface{}{ 222 | "line": line - 1, 223 | "character": character - 1, 224 | }, 225 | } 226 | type sigInfo struct { 227 | Label string `json:"label"` 228 | Docs string `json:"documentation,omitempty"` 229 | Params []map[string]interface{} `json:"parameters,omitempty"` 230 | } 231 | reply := struct { 232 | Signatures []sigInfo `json:"signatures"` 233 | AParam int `json:"activeParameter,omitempty"` 234 | ASig int `json:"activeSignature,omitempty"` 235 | }{} 236 | err := ls.execCommandSync("textDocument/signatureHelp", paramMap, &reply) 237 | if err != nil { 238 | return "echo 'Command failed'" 239 | } 240 | if len(reply.Signatures) == 0 { 241 | return "echo 'No signatures found'" 242 | } 243 | for i := 0; i < len(reply.Signatures); i++ { 244 | s := reply.Signatures[i] 245 | s.Docs = strings.Replace(s.Docs, "\\'", "\\\\'", -1) 246 | s.Docs = strings.Replace(s.Docs, "'", "\\'", -1) 247 | s.Docs = strings.Replace(s.Docs, "\"", "\\\"", -1) 248 | reply.Signatures[i] = s 249 | } 250 | fmt.Println(reply) 251 | return fmt.Sprintf("info -placement below -anchor %s.%s '%s\n%s'", params[0], params[1], reply.Signatures[reply.ASig].Label, reply.Signatures[reply.ASig].Docs) 252 | } 253 | -------------------------------------------------------------------------------- /lsc.kak: -------------------------------------------------------------------------------- 1 | echo -debug "Loading lsc.kak" 2 | 3 | #Welp, this is actually becoming a problem... Move configs into an actual config file? 4 | decl -docstring "Max filesize to auto-sync in KiB" int lsc_max_sync_size 1024 5 | 6 | #Mapping filetype to command to launch a language server 7 | decl -hidden str lsc_langservers %{ 8 | go:go-langserver 9 | } 10 | 11 | #Manually launch the language client binary 12 | hook global NormalKey 0 %{ nop %sh{ 13 | (/mnt/e/GoWorkspace/bin/kakoune-languageclient $kak_session $kak_client) > /dev/null 2>&1 < /dev/null & 14 | }} 15 | 16 | #Send a ping to the client that will write into the debug buffer 17 | #Useful for debugging 18 | hook global -group lsc NormalKey D %{ nop %sh{ 19 | (printf "Ping\n" >> $kak_opt_lsc_pipe) 20 | }} 21 | 22 | #Used to cleanup the client and servers when Kakoune closes 23 | hook global -group lsc KakEnd .* %{ nop %sh{ 24 | (printf "KakEnd\n" >> $kak_opt_lsc_pipe) 25 | }} 26 | 27 | #Send textDocument/hover command 28 | def lsc-hover %{ nop %sh{ 29 | (printf "%s:%s:%s:textDocument/hover:%s,%s,%s\n" $kak_opt_filetype $kak_buffile $kak_timestamp $kak_cursor_line $kak_cursor_char_column) >> $kak_opt_lsc_pipe } 30 | } 31 | 32 | #Send textDocument/signatureHelp command 33 | def lsc-sig-help %{ nop %sh{ 34 | (printf "%s:%s:%s:textDocument/signatureHelp:%s,%s,%s\n" $kak_opt_filetype $kak_buffile $kak_timestamp $kak_cursor_line $kak_cursor_char_column) >> $kak_opt_lsc_pipe } 35 | } 36 | 37 | #Manual bindings for commands 38 | map -docstring %{Hover help} global user h ':lsc-hover' 39 | map -docstring %{Signature help} global user b ':lsc-sig-help' 40 | --------------------------------------------------------------------------------