Thanks to BetterDoc’s 20% time for personal development1, I recently had the opportunity to add a little feature to elixir-ls, the language server that provides the smarts behind autocompletion - among other things - for the vscode-elixir plugin.

This is a post to jot down my learnings from trying to figure out how to develop the feature and the workflow I ended up using. Maybe this helps the next person looking to scratch an itch but doesn’t know where to start ;)

How the plugin works

The protocol

Language servers that cater to a specific language tend to be written in the same language that they provide the smarts for2. For VSCode (and other editors) then to be able to communicate with a language server in a manner that is not tied to a particular language, the language server needs to implement an API on top of the rest of the code according to the LSP protocol specification which is a JSON-RPC based protocol.

The flow is of the request-response persuasion: As the user is taking actions inside the editor, the editor is making requests to the language server providing it with details about what just happened (file opened, text typed etc.) and the language server responds with the appropriate suggestions (if any).

vscode-elixir plugin specifics

The plugin is packaged as 2 distinct pieces of functionality:

  • a thin layer written in Typescript that is responsible for implementing VSCode plugin specific callbacks like e.g. server startup and tear down, registering appropriate file types and returning a client that propagates JSON-RPC messages to the server etc.
  • the actual language server that is started up as a separate OS process which is an instance of the BEAM VM running the language server Elixir code.

What happens then is the Typescript layer which runs inside the editor process opens up a pipe for communicating with the language server process (IPC) to exchange the JSON-RPC messages of the LSP protocol.

Preparing the development environment

The Contributing section of the plugin’s README file has instructions on how to package and launch the plugin - but I TLDRd3 since the process looked a bit involved and I was looking for a faster feedback loop.

On macOS, VSCode seems to install plugins under ~/.vscode/extensions/. Looking inside ~/.vscode/extensions/jakebecker.elixir-ls-0.9.0/elixir-ls-release, there’s a bunch of .ez files (packaged BEAM modules) and a few script files, launch.sh and launch.bat among them (go Windows!). The last line inside the script reads:

exec elixir --erl "+sbwt none +sbwtdcpu none +sbwtdio none"  -e "$ELS_SCRIPT"

$ELS_SCRIPT evaluates to ElixirLS.LanguageServer.CLI.main() (it’s set in the real_language_server.sh file), so now we know how the language server code is shipped and what the entry point into the language server is. Nice.

I cloned the language server code under ~/code/elixir-lsp/elixir-ls and since I needed the plugin to use the code from that directory instead of the pre-packaged .ez archives, I went ahead and made the following modifications to the launch.sh script4

- exec elixir --erl "+sbwt none +sbwtdcpu none +sbwtdio none"  -e "$ELS_SCRIPT"
+ cd ~/code/elixir-lsp/elixir-ls
+ exec elixir --erl "+sbwt none +sbwtdcpu none +sbwtdio none" -S mix run -e "$ELS_SCRIPT" --no-halt

Now the plugin would be loading my local code using mix, which means that after making a change the plugin can be reloaded from inside the editor using Command Palette (cmd+shift+p) -> Developer: Restart Extension Host which will force mix to recompile the project before starting the language server again.

Tip: you can see the plugin info messages by opening the Output pane with Command Palette -> View: Toggle Output and then selecting ElixirLS - my_project from the dropdown (you need to have an .ex file open in your editor view). In case something breaks while making changes you can use it for debugging the issue.

At this point I did know the entrypoint but I still didn’t know the specific code file I’d need to go to for my changes. So I decided to take another shortcut.

Elixir devs tend to think about pattern matching when trying to match incoming messages to actions - and I decided to pursue that avenue: find out the message being sent by the editor when auto-completing code and use that to discover the code responsible for handling defmodule ... (the feature I wanted to add). I went back into the launch script and made another change:

- exec elixir --erl "+sbwt none +sbwtdcpu none +sbwtdio none" -S mix run -e "$ELS_SCRIPT" --no-halt
+ exec tee -a ./elixir-lsp.log | elixir --erl "+sbwt none +sbwtdcpu none +sbwtdio none" -S mix run -e "$ELS_SCRIPT" --no-halt | tee -a ./elixir-lsp.log

*nix power! By using tee I could now intercept both incoming and outgoing messages and log them into a file before forwarding them onto the language server.

Discovering the correct code file

Creating a new project and a new foo.ex file I typed defmod and looked at what messages are exchanged with tail -f elixir-lsp.log. Here’s what the request looks like

Content-Length: 215

{"jsonrpc":"2.0","id":86,"method":"textDocument/completion","params":{"textDocument":{"uri":"file:///Users/xxx/code/my_project/lib/my_project/foo.ex"},"position":{"line":1,"character":6},"context":{"triggerKind":3}}}

Looking for matches on the method textDocument/completion leads to the ElixirLS.LanguageServer.Providers.Completion.completion/4 method. Now it’s only a matter of adding the necessary code5… et voilà :)

Outro

That was all. I was particularly happy of how amenable to introspection6 the elixir-ls setup is, which allows people totally unfamiliar with the project to zero-in on the right place to add their contributions to. I hope you enjoyed reading this little adventure!

Thanks goes out to my colleagues Alex L. Z. and Frerich R. for reviewing this post.

  1. At BetterDoc every Friday is dedicated to advancing one’s knowledge on any subject of their choosing as long as it is related to programming. At the end of the day we share what we learned with everyone else. 

  2. Which I guess makes sense because the set of potential contributors for the language server for language X is probably a subset of the users of language X - and developing the server in X practically removes the language barrier for contributors. 

  3. I was eager to find the place to add my changes, and reading diligently through the server code and the docs was not what I wanted to start with because that would take the wind out of me. 

  4. If you’re doing this on Windows, changing launch.bat alone was not enough for me: I had to delete the .ez files (but leave the elixir-sense archive in!) before my code would load. 

  5. As always, the trivial task of adding the appropriate code is left as an exercise to the reader. 

  6. Another cool feature that I noticed was how IO.inspect/2 messages were converted into JSON-RPC info messages by the authors of the plugin. There’s probably a lesson in process group leaders to be learned there but for the moment I was just happy I could take advantage of it.