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 ===")
    break
except IOError:
    print("=== socket closed ===")
    break
if data == '':
    print("=== socket closed ===")
    break
try:
    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)
endfunc

let 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) < 6
        let data = ch_readraw(w:channel, {"timeout":2000})
        let w:output = w:output . data
        if strlen(data) == 0
            return ""
        endif
    endwhile
    let kount = str2nr(strpart(w:output, 0, 6), 16)
    while strlen(w:output) < kount + 6
        let data = ch_readraw(w:channel, {"timeout":2000})
        let w:output = w:output . data
        if strlen(data) == 0
            return ""
        endif
    endwhile
    let msg = strpart(w:output, 6, kount)
    let w:output = strpart(w:output, 6+kount)
    return msg
endfunc

func! 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 = 0
    let sexpr = []
    let context = []
    while 1
        let 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) > 0
            let top = remove(context, -1)
            call add(top, sexpr)
            let sexpr = top
        elseif 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) == 0
            if len(sexpr) == 1
                return remove(sexpr, 0)
            else
                return {'syntax_error':start, 'sexpr':sexpr, 'context':context, 'msg':(a:msg)}
            endif
        else
            return {'syntax_error':start, 'sexpr':sexpr, 'context':context, 'msg':(a:msg)}
        endif
        let start = start + strlen(head)
    endwhile
endfunc

func! IsSyntaxError(item)
    if type(a:item) == v:t_dict
        return has_key(a:item, 'syntax_error')
    endif
    return 0
endfunc

func! IsList(item)
    return type(a:item) == v:t_list
endfunc

func! IsString(item)
    return type(a:item) == v:t_string
endfunc

func! IsNumber(item)
    return type(a:item) == v:t_number
endfunc

func! IsCommand(item)
    if type(a:item) == v:t_dict
        return has_key(a:item, 'command')
    endif
endfunc

func! ToSExpr(item)
    if IsList(a:item)
        let forms = []
        for subitem in a:item
            call add(forms, ToSExpr(subitem))
        endfor
        return '(' . 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']
    endif
endfunc

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:null
let w:output = v:null
func! 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))
    endif
  endif
endfunc

let w:messages = []
func! IdrisMessage(msg)
    call add(w:messages, a:msg)
endfunc

func! 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)
endfunc

func! 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 10split
    badd idris-response
    b idris-response
    let g:idris_respwin = "active"
    set buftype=nofile
    wincmd k
  elseif (bufexists("idris-response") && g:idris_respwin == "hidden")
    botright 10split
    b idris-response
    let g:idris_respwin = "active"
    wincmd k
  endif
endfunction

function! IdrisHideResponseWin()
  let g:idris_respwin = "hidden"
endfunction

function! IdrisShowResponseWin()
  let g:idris_respwin = "active"
endfunction

au 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
    %delete
    let resp = split(a:str, '\n')
    let n = len(resp)
    let c = 0
    while c < n
        call setbufline("idris-response", c+1, resp[c])
        let c = c + 1
    endwhile
    b #
    call setpos('.', save_cursor)
    call winrestview(win_view)
  else
    echo a:str
  endif
endfunction

function! IAppend(str)
  if (bufexists("idris-response"))
    let win_view = winsaveview()
    let save_cursor = getcurpos()
    b idris-response
    let resp = split(a:str, '\n')
    call append(line('$'), resp)
    b #
    call setpos('.', save_cursor)
    call winrestview(win_view)
  endif
endfunction

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 = 0
let w:pending_requests = {}
func! IdrisMessage(msg)
    if !IsList(a:msg)
        echoerr printf("idris ide message that is not a list: %s", a:msg)
        return 0
    endif
    if !IsCommand(a:msg[0])
        echoerr printf("idris ide message that is not a command: %s", a:msg)
        return 0
    endif
    let 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])
        endif
    elseif 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.
    else
        echoerr printf("unknown idris ide message: %s", a:msg)
    endif
endfunc

func! PrepareSending()
    if w:protocol_version == 0
        echoerr "protocol not initialized"
    endif
    return w:protocol_version
endfunc

let w:next_message_id = 1
func! IdrisSendMessage(command)
    let this_message_id = w:next_message_id
    let msg = ToSExpr([a:command, this_message_id]) . "\n"
    let w:next_message_id = w:next_message_id + 1
    call Write6HexMessage(msg)
    return this_message_id
endfunc

func! IdrisRequest(command, req)
    let w:pending_requests[w:next_message_id] = a:req
    call IdrisSendMessage(a:command)
endfunc

func! DefaultResponse(req, command)
    call IWrite(printf("%s", ToSExpr(a:command)))
endfunc
let 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 1
echo 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 2
echo 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.

Similar posts