Idris2 & Vim channels & Vim plugin development
This time, lets look into Vim channels. The channel.txt -documentation is superb and I would really have no need to write this. You might be interested in knowing what kind of a task it is though. Going through implementing something for Vim.
Easiest way to start with this is to run the demoserver.py script
that you find from $VIMRUNTIME/tools.
Then do this:
let channel = ch_open('localhost:8765')echo ch_evalexpr(channel, 'hello!')
To run this write that script into a Vim buffer,
then VISUAL-yank it and type :@*.
The terminal where you've opened the demoserver.py
should show you the following or similar,
depending on what you wrote to evalexpr.
received: [1,"hello"]sending [1, "what?"]received: [2,"hello"]sending [2, "what?"]received: [3,"hello!"]sending [3, "got it"]
So it does what we want it to do. Lets check inside the script and see if we can make something out of it. Inside the read-handler there's a putrid surprise left for the reader:
try:data = self.request.recv(4096).decode('utf-8')except socket.error:print("=== socket error ===")breakexcept IOError:print("=== socket closed ===")breakif data == '':print("=== socket closed ===")breaktry:decoded = json.loads(data)except ValueError:print("json decoding failed")decoded = [-1, '']
Eww. It's reading a fixed-size message from Vim, discards the rest of it and then stumbles on the truncated part on the subsequent reads.
It takes at least 20 lines to write a quirky fix and 2000 lines to provide a proper fix somebody ought have already provided into the standard libraries because json protocols are popular these days.
I was thinking of writing an Idris IDE-protocol driver in Python. Yeah no, lets leave Python3 aside and do things in vimscript.
Btw. There's a nice online book that teaches you vimscript. I've read this at least once and I read parts of it again while writing this.
Protocol we implement
The Idris IDE protocol messages start with 6-character hexadecimal number that communicates how long the message packet is. It's good to start by finding out how to translate the hexadecimal header.
It appears to be easy as there are methods for hexadecimal conversion.
echo str2nr('023029', 16)echo printf('%06x', 2341)
And it's also straightforward to get the length of a string.
echo strlen("foo")Next lets try start Idris2 through Vim.
let w:output = ""func! IdrisHandle(channel, msg)let w:output = (w:output . a:msg)endfunclet job = job_start("idris2 --ide-mode", {"mode":"raw", "callback":"IdrisHandle"})let w:channel = job_getchannel(job)let output = ch_readraw(channel)call ch_close(channel)call job_stop(job)
The :echo output should show:
000018(:protocol-version 2 0)An another useful command is put =output if you want to write the results
of a variable to examine it for a longer while.
Now we have to decode and encode the first layer of these messages.
func! Read6HexMessage()while strlen(w:output) < 6let data = ch_readraw(w:channel, {"timeout":2000})let w:output = w:output . dataif strlen(data) == 0return ""endifendwhilelet kount = str2nr(strpart(w:output, 0, 6), 16)while strlen(w:output) < kount + 6let data = ch_readraw(w:channel, {"timeout":2000})let w:output = w:output . dataif strlen(data) == 0return ""endifendwhilelet msg = strpart(w:output, 6, kount)let w:output = strpart(w:output, 6+kount)return msgendfuncfunc! Write6HexMessage(msg)let kount = printf('%06x', strlen(a:msg))call ch_sendraw(w:channel, kount . a:msg)endfunc
The Read6HexMessage is waiting for more if the message isn't long enough to be read. To not hung up we step if the program doesn't progress, by checking whether there was more data received.
Later when writing this I realised the messages drop without the callback.
Apparently I need to add 'drop':'never' when opening the channel.
But it still didn't work with the very first command,
so I went async with this.
Next we would like to interpret what's in each message. The command syntax for the Idris IDE is quite simple:
A ::= NUM | '"' STR '"' | ':' ALPHA+S ::= A | '(' S* ')' | nil
After few trials I ended up with the following readers and writers for the command syntax:
func! FromSExpr(msg)let start = 0let sexpr = []let context = []while 1let start = start + strlen(matchstr(a:msg, '^\_s\+', start))let head = matchstr(a:msg, '^:[:\-a-zA-Z]\+\|^(\|^)\|^\d\+\|^nil\|^"\([^"]\|\"\)\{-}"', start)if head =~ '^('call add(context, sexpr)let sexpr = []elseif head =~ '^)' && len(context) > 0let top = remove(context, -1)call add(top, sexpr)let sexpr = topelseif head =~ '^:'call add(sexpr, {'command':strpart(head, 1)})elseif head =~ '^\d'call add(sexpr, str2nr(head))elseif head =~ '^"'let item = substitute(strpart(head, 1, strlen(head) - 2), '\\"', '"', 'g')call add(sexpr, item)elseif head =~ '^nil'call add(sexpr, [])elseif start == strlen(a:msg) && len(context) == 0if len(sexpr) == 1return remove(sexpr, 0)elsereturn {'syntax_error':start, 'sexpr':sexpr, 'context':context, 'msg':(a:msg)}endifelsereturn {'syntax_error':start, 'sexpr':sexpr, 'context':context, 'msg':(a:msg)}endiflet start = start + strlen(head)endwhileendfuncfunc! IsSyntaxError(item)if type(a:item) == v:t_dictreturn has_key(a:item, 'syntax_error')endifreturn 0endfuncfunc! IsList(item)return type(a:item) == v:t_listendfuncfunc! IsString(item)return type(a:item) == v:t_stringendfuncfunc! IsNumber(item)return type(a:item) == v:t_numberendfuncfunc! IsCommand(item)if type(a:item) == v:t_dictreturn has_key(a:item, 'command')endifendfuncfunc! ToSExpr(item)if IsList(a:item)let forms = []for subitem in a:itemcall add(forms, ToSExpr(subitem))endforreturn '(' . join(forms, ' ') . ')'elseif IsString(a:item)return '"' . substitute(a:item, '"', '\"', 'g') . '"'elseif IsNumber(a:item)return printf("%d", a:item)elseif IsCommand(a:item)return ":" . (a:item)['command']endifendfunc
Now we are able to communicate with the IDE prompt:
let msg = ToSExpr([[{'command':'load-file'}, "Test"], 1]) . "\n"call Write6HexMessage(msg)let msg = ToSExpr([[{'command':'type-of'}, "test"], 2]) . "\n"call Write6HexMessage(msg)
The results go to the w:output.
I am quite not certain about what I received as the output, so lets continue with the IDE-mode of earlier Idris that we know to work:
let w:output = ""let w:channel = v:nulllet w:output = v:nullfunc! IdrisHandle(channel, msg)let w:output = (w:output . a:msg)if 6 <= strlen(w:output)let kount = str2nr(strpart(w:output, 0, 6), 16)if kount + 6 <= strlen(w:output)let data = strpart(w:output, 6, kount)let w:output = strpart(w:output, 6+kount)call IdrisMessage(FromSExpr(data))endifendifendfunclet w:messages = []func! IdrisMessage(msg)call add(w:messages, a:msg)endfuncfunc! IdrisStart()let w:messages = []let w:output = ""let w:job = job_start("idris --ide-mode", {'mode':'raw', 'callback':'IdrisHandle'})let w:channel = job_getchannel(w:job)endfuncfunc! IdrisStop()call ch_close(w:channel)call job_stop(w:job)endfunc
Now we are done with the main interface. This didn't all come out at the first iteration. Lot of small fixes were added while working on this.
Next we start peeking inside the original idris-vim -plugin.
Response window
The first thing that's needed is the response window. Obviously we'd have already needed it.
function! IdrisResponseWin()if (!bufexists("idris-response"))botright 10splitbadd idris-responseb idris-responselet g:idris_respwin = "active"set buftype=nofilewincmd kelseif (bufexists("idris-response") && g:idris_respwin == "hidden")botright 10splitb idris-responselet g:idris_respwin = "active"wincmd kendifendfunctionfunction! IdrisHideResponseWin()let g:idris_respwin = "hidden"endfunctionfunction! IdrisShowResponseWin()let g:idris_respwin = "active"endfunctionau BufHidden idris-response call IdrisHideResponseWin()au BufEnter idris-response call IdrisShowResponseWin()function! IWrite(str)if (bufexists("idris-response"))let win_view = winsaveview()let save_cursor = getcurpos()b idris-response%deletelet resp = split(a:str, '\n')let n = len(resp)let c = 0while c < ncall setbufline("idris-response", c+1, resp[c])let c = c + 1endwhileb #call setpos('.', save_cursor)call winrestview(win_view)elseecho a:strendifendfunctionfunction! IAppend(str)if (bufexists("idris-response"))let win_view = winsaveview()let save_cursor = getcurpos()b idris-responselet resp = split(a:str, '\n')call append(line('$'), resp)b #call setpos('.', save_cursor)call winrestview(win_view)endifendfunction
Unfortunately I don't figure out how to scroll the response window when more text comes in.
Newer vimscript has macros to do at least the current thing without having to fiddle with user's view and cursor. Latest Ubuntu LTS is still going at 8.0 though. Anyway it's time to add the window prompt into the callback function.
func! IdrisMessage(msg)call IAppend(printf("%s", a:msg))endfunc
Next if we write anything into the output
[{'command': 'protocol-version'}, 1, 0][{'command': 'output'},[{'command': 'ok'},[{'command': 'highlight-source'},[[[[{'command': 'filename'}, 'Test.idr'],[{'command': 'start'}, 1, 8],[{'command': 'end'}, 1, 11]],[[{'command': 'namespace'}, 'Test'],[{'command': 'decor'}, {'command': 'module'}],[{'command': 'source-file'}, '/path/to/Test.idr']]]]]],1][{'command': 'set-prompt'}, '*Test', 1][{'command': 'return'}, [{'command': 'ok'}, []], 1][{'command': 'return'},[{'command': 'ok'}, 'test : Type', [[0, 4, [[{'command': 'name'}, 'Test.test'], [{'command': 'implicit'}, {'command': 'False'}],[{'command': 'key'}, 'AQAAAAAAAAAABHRlc3QAAAAAAAAAAQAAAAAAAAAEVGVzdA=='],[{'command': 'decor'}, {'command': 'function'}], [{'command': 'doc-overview'}, ''],[{'command': 'type'}, 'Type'], [{'command': 'namespace'}, 'Test']]],[7, 4, [[{'command': 'decor'}, {'command': 'type'}], [{'command': 'type'}, 'Type'],[{'command': 'doc-overview'}, 'The type of types'], [{'command': 'name'}, 'Type']]],[7, 4, [[{'command': 'tt-term'}, 'AAAAAAAAAAAHAAAAAAAAAAAKLi9UZXN0LmlkcgAAAAAAAAAW']]]]],2]
Aside having to track the message numbers ourselves, we can now pass in commands and see what they do.
let msg = ToSExpr([[{'command':'interpret'}, "1 + 2"], 4]) . "\n"call Write6HexMessage(msg)
It gives something like this:
[{'command': 'return'}, [{'command': 'ok'}, '3 : Integer',[[0, 1, [[{'command': 'decor'}, {'command': 'data'}], [{'command': 'type'}, 'Integer'],[{'command': 'doc-overview'}, 'An arbitrary-precision integer'], [{'command': 'name'}, '3']]],[0, 1, [[{'command': 'tt-term'}, 'AAAAAAAAAAAEAQAAAAAD']]],[4, 7, [[{'command': 'decor'}, {'command': 'type'}],[{'command': 'type'}, 'Type'],[{'command': 'doc-overview'}, 'Arbitrary-precision integers'],[{'command': 'name'}, 'Integer']]],[4, 7, [[{'command': 'tt-term'}, 'AAAAAAAAAAAECg==']]]]],3]
Soon we can start adding existing commands from vim-idris. The system we are about to build needs to keep track of pending commands and ensure they work with the new protocol as well.
Protocol version&response tracking
Since the system courteously tells us the version in the beginning and there are two versions to support, it'd be a great idea to use this on determining which way the interface works. We are going to do that.
The first element in each command appears to be a command to do something, so we start by looking at that. If the message isn't what we care about, it can write as 'echoerr'.
let w:protocol_version = 0let w:pending_requests = {}func! IdrisMessage(msg)if !IsList(a:msg)echoerr printf("idris ide message that is not a list: %s", a:msg)return 0endifif !IsCommand(a:msg[0])echoerr printf("idris ide message that is not a command: %s", a:msg)return 0endiflet name = a:msg[0]['command']if name == 'return' && IsNumber(a:msg[2])if has_key(w:pending_requests, a:msg[2])let req = remove(w:pending_requests, a:msg[2])call req.ok(req, a:msg[1])endifelseif name == 'output' && IsNumber(a:msg[2])elseif name == 'protocol-version' && IsNumber(a:msg[1])let w:protocol_version = a:msg[1]elseif name == 'set-prompt' && IsString(a:msg[1])" doing nothing on this message.elseechoerr printf("unknown idris ide message: %s", a:msg)endifendfuncfunc! PrepareSending()if w:protocol_version == 0echoerr "protocol not initialized"endifreturn w:protocol_versionendfunclet w:next_message_id = 1func! IdrisSendMessage(command)let this_message_id = w:next_message_idlet msg = ToSExpr([a:command, this_message_id]) . "\n"let w:next_message_id = w:next_message_id + 1call Write6HexMessage(msg)return this_message_idendfuncfunc! IdrisRequest(command, req)let w:pending_requests[w:next_message_id] = a:reqcall IdrisSendMessage(a:command)endfuncfunc! DefaultResponse(req, command)call IWrite(printf("%s", ToSExpr(a:command)))endfunclet w:default_response = {'ok':function("DefaultResponse")}
For now there's possibility that protocol would send malformed messages and cause our handler to fail. As long as script variables remembered across calls stay good, I guess I just accept that.
The PrepareSending is there in case that we want to start the protocol up.
Also the w:protocol_version is returned in this manner because
it may be temporarily down.
Additionally the protocol version need to be handled
by the IdrisStart/IdrisStop so we are going to
add it there later when we modify it again.
Now we can try this.
echo IdrisSendMessage([{'command':'load-file'}, "Test"])echo IdrisRequest([{'command':'type-of'}, "test"], w:default_response)echo IdrisRequest([{'command':'case-split'}, line, name], w:default_response)echo IdrisRequest([{'command':'proof-search'}, line, name, hints], w:default_response)only in version 1echo IdrisRequest([{'command':'interpret'}, "1 + 2"], w:default_response)echo IdrisRequest([{'command':'add-clause'}, line, name], w:default_response)echo IdrisRequest([{'command':'add-proof-clause'}, line, name], w:default_response)echo IdrisRequest([{'command':'add-missing'}, line, name], w:default_response)echo IdrisRequest([{'command':'make-with'}, line, name], w:default_response)echo IdrisRequest([{'command':'make-case'}, line, name], w:default_response)echo IdrisRequest([{'command':'make-lemma'}, line, name], w:default_response)echo IdrisRequest([{'command':'docs-for'}, name], w:default_response)echo IdrisRequest([{'command':'apropos'}, name], w:default_response)echo IdrisRequest([{'command':'metavariables'}, 80], w:default_response)echo IdrisRequest([{'command':'who-calls'}, name], w:default_response)echo IdrisRequest([{'command':'calls-who'}, name], w:default_response)echo IdrisRequest([{'command':'browse-namespace'}, namespace], w:default_response)echo IdrisRequest([{'command':'print-definition'}, name], w:default_response)echo IdrisRequest({'command':'version'}, w:default_response)only in version 2echo IdrisRequest([{'command':'generate-def'}, line, name], w:default_response)
For now all the script has been sitting in the blogtext file. Things are going so well that I fork the repository in github and transfer these changes there.
Idris plugin changes
The only file I really have to change is the ftplugin/idris.vim.
I write nice bundle out of everything and move it down there.
Mostly I have to replace all 'w:' with 's:' -prefixes and rewrite the protocol handling few times. It also needs somoe documentation for the connection/disconnection features that become available here.
The plugin seems to be working occassionally. Sometimes I get the message I suppose I was meant to get every time when I load a file, other times I don't get that.
I decided that it's time to show idris-hackers what I got here and go onward from there.