Elm Language Server and neovim

Configure Languge-Client-neovim to work with elm-language-server

When Visual Studio Code was released, it brought with it a feature called "Language Servers." Since then, language servers have grown incredibly popular, not just due to Visual Studio Code's popularity, but also due to the implementation of Language Clients in other editors.

Language Servers are a special type of program that implement a standard API for interacting with a programming language's compiler or interpreter to provide features like code completion, project-wide identifier renaming, syntax checking, auto-formatting, and more. A variety of languages have language server implementations, and a full list can be found on Languageserver.org. But this post is specifically about Elm.

The problem

The reason I looked into the Elm language server was due to an issue I ran into when running elm 0.19, elm-vim, and deoplete together. The problem boiled town to elm-vim's reliance on a tool called elm-oracle, which doesn't work with the current version of Elm, Elm 0.19. The problem manifested itself when Deoplete was running, since deoplete tried to reach into elm-oracle for static analysis at every keystroke, and ended up slowing vim to put errors into the vim modeline.

The simple solution to this problem was to stop using one of these three tools, and for a bit, I settled for not having code-completion through deoplete, but if you've gotten used to code completion, it's hard to work without it.

Enter Elm Language Server

Language Servers promise to solve this issue of "does X editor implement support for Y langauge" by changing the question to "does X editor have a language client and does Y language have a language server." While this sounds more complicated, it allows multiple editors to share the same language tooling, meaning there's less effort overall.

The Elm Language Server is a node-js application implementing the full language-server feature set for Elm 0.19, and they provide setup instructions for coc.nvim, a language-client implementation for Vim. But for external reasons, I had already installed Language-Client-neovim, so my task was to translate the setup instructions from one tool to the other.

The Setup

1. Install Node.js

Starting from nothing, we need Node.js to do anything. My preference is to install node via nodenv, a node version manager, but other installation methods can be found on the Node.js website.

With nodenv

$ nodenv install 11.7.0
$ nodenv rehash
$ nodenv global 11.7.0
$ npm i -g npm

With apt

$ apt install nodejs npm

With brew

$ brew install node

2. Install Elm tooling

We'll need to install a few packages to make Elm and the Elm Language Server work properly.

First, we'll install Elm, the Elm Formatter, and the Elm Test utility

$ npm i -g elm elm-format elm-test

Then we can install the language server

$ npm i -g @elm-tooling/elm-language-server

To make setting up a new Elm application easier, we'll also grab create-elm-app

$ npm i -g create-elm-app

3. Configure Vim

There's a few good plugin managers for Vim and Neovim, but the one I prefer is vim-plug. For the purposes of this tutorial, I'll use vim-plug.

Add deoplete, Language-Client-neovim, fzf, and vim-polyglot to your vim config.

  • for vim, this is normally ~/.vimrc
  • for neovim, this is normally ~/.config/nvim/init.vim

Here's what each of these plugins do

  • Language-Client-neovim, talk to the elm-language-server
  • deoplete, provide code-completion functionality
  • fzf, provide extra functionality for menus in the language client
  • vim-polyglot, Syntax highlighting and basic support for many languages (including Elm)
call plug#begin('~/.config/nvim/plugged')

Plug 'autozimu/LanguageClient-neovim', {
    \ 'branch': 'next',
    \ 'do': 'bash install.sh',
    \ }
Plug 'junegunn/fzf', { 'dir': '~/.fzf', 'do': './install --all' }
Plug 'sheerun/vim-polyglot'
Plug 'Shougo/deoplete.nvim', { 'do': ':UpdateRemotePlugins' }

call plug#end()

Then, close and reopen vim and run :PlugInstall in the modeline.

After the plugins install, open up the vim config again. Now we're going to configure Deoplete to start when vim is launched, and to use the tab key to do code completion. We're also going to tell the LanguageClient how to find the root of an Elm project, and how to launch the Language Server. Finally, we're going to set up some keybindings for common functions.

" Enable deoplete
let g:deoplete#enable_at_startup = 1

" Bind the tab key
inoremap <expr><tab> pumvisible() ? "\<c-n>" : "\<tab>"

" Launch the Elm Language Server
let g:LanguageClient_serverCommands = {
    \ 'elm': ['elm-language-server', '--stdio']
    \ }

" Find the root of an Elm 0.19 project
let g:LanguageClient_rootMarkers = {
    \ 'elm': ["elm.json"]
    \ }

function LC_maps()
  if has_key(g:LanguageClient_serverCommands, &filetype)
    " Bind K to show documentation for the current symbol
    nnoremap <buffer> <silent> K :call LanguageClient#textDocument_hover()<cr>
    " Bind gd to go to the definition of a symbol
    nnoremap <buffer> <silent> gd :call LanguageClient#textDocument_definition()<CR>
    " Bind <F2> to global rename
    nnoremap <buffer> <silent> <F2> :call LanguageClient#textDocument_rename()<CR>
    " Bind <F5> to the context menu for more options
    nnoremap <buffer> <silent> <F5> :call LanguageClient_contextMenu()<CR>
  endif
endfunction

" Execute the bindings for supported languages
autocmd FileType * call LC_maps()

4. Create an Elm Project

It's time to create an Elm project with language-server support!

First, let's create the project

$ create-elm-app my-app
$ cd my-app

The Elm app that we've created here is a great starting point for any app. There's a lot here, so I'll leave you to read the README.md file.

$ tree          
.
├── elm.json
├── elm-stuff
│   └── 0.19.0
│       ├── Main.elmi
│       ├── Main.elmo
│       └── summary.dat
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo.svg
│   └── manifest.json
├── README.md
├── src
│   ├── index.js
│   ├── main.css
│   ├── Main.elm
│   └── registerServiceWorker.js
└── tests
    └── Tests.elm

Next, let's tell the language server where it's components are. We can do this for all elm projects using the g:LanguageClient_settingsPath variable in the vim config, or on a per-project basis. Here, we'll do it per-project.

$ mkdir .vim
$ vim .vim/settings.json

Here's the contents of settings.json:

{
    "initializationOptions": {
        "runtime": "node",
        "elmPath": "elm",
        "elmFormatPath": "elm-format",
        "elmTestPath": "elm-test"
    }
}

This file can have custom paths to each of these binaries if your situation needs that, but in most cases, just putting the names of each program should be fine.

You're Done!

That took a bit of time, but we went from having nothing to creating an Elm project with the elm-language-server running from vim!

Feel free to leave comments or questions, I'll try my best to respond to everything!

You can find me here, on mastodon at @asonix@asonix.dog, or by email at asonix@asonix.dog.

The code in this post is licensed under the MIT License.