(neo)vim and Haskell, 2021 edition

October 4, 2021 // haskell

In this post, I’ll describe my setup for doing Haskell (which I almost exclusively do with stack-based projects).

Spoiler: it’s much, much more straightforward than a few years ago, almost to the point of “vim and Haskell” posts being no longer necessary.

Before delving into details, I’d like to note that, although my configuration applies to both vim and neovim, I recently switched to neovim for my Haskell hacking, so there will be some neovim-specific things (and I’ll try to emphasize those specifically).

IDE features

Autocomplete, linting, displaying errors as you type, looking up documentation — all that happens via CoC. CoC is a universal Language Server Protocol client; hence a server is also needed. Luckily, there’s one brand new thing for Haskell, unsurprisingly called Haskell Language Server or hls for short.

What works?

First of all, perhaps the most important thing for a language like Haskell, case splitting and hole-driven development, finally works, and it works great! It’s going as far as, in some cases, generating function definitions based solely on types:

Perhaps proof assistants have spoiled me, but that’s just effing awesome!

It’s also sufficiently smart to do the right thing even when there are simpler but incorrect solutions:

That’s quite unlike some proof assistants, which, in cases like this, might produce something along the lines of

map :: (a -> b) -> [a] -> [b]
map _ _ = []

Yes, this surely typechecks, but it certainly isn’t what you want! Luckily, hls is smart enough to do the right thing.

Moreover, there’s this other cool feature giving your old boring (neo)vim a hip Jupyter Notebook vibe, allowing one to evaluate expressions right in the editor:

This feature is excellent for exploratory programming or getting acquainted with a new library without even opening ghci!

Then, most of the more boring stuff you’d expect from an IDE for your favourite language works:

What’s missing or broken?

What I don’t care about, and hence don’t know if it works or not?

Configuring things

Both CoC and hls repos linked above have pretty decent documentation. I strongly recommend reading it to understand what these things can do, but here’s my tl;dr (modulo the installation instructions, which depend on your OS/distro).

CoC

Run :CocConfig in vim and paste this:

{
    "languageserver": {
      "haskell": {
        "command": "haskell-language-server-wrapper",
        "args": ["--lsp"],
        "rootPatterns": [
          "stack.yaml",
          "*.cabal",
          "cabal.config",
          "package.yaml",
          "hie.yaml"
        ],
        "filetypes": [ "hs", "lhs", "haskell" ],
        "initializationOptions": { "languageServerHaskell": {} }
      }
    },
    "suggest": {
      "disableKind": true,
      "snippetsSupport": false
    },
    "diagnostic": {
      "virtualText": true,
      "virtualTextCurrentLineOnly": false,
      "virtualTextLines": 1,
      "virtualTextPrefix": " —— "
    },
    "list.height": 20,
    "codeLens.enable": true,
    "coc.preferences.enableMarkdown": true,
    "coc.preferences.jumpCommand": "tab drop"
}

The only required config group is the languageserver one. The rest is primarily some neat extra features or purely a matter of taste:

(neo)vim

Here’s what I have in my ~/.vimrc:

map <Leader>ggd <Plug>(coc-definition)
map <Leader>ggi <Plug>(coc-implementation)
map <Leader>ggt <Plug>(coc-type-definition)
map <Leader>gh :call CocActionAsync('doHover')<cr>
map <Leader>gn <Plug>(coc-diagnostic-next)
map <Leader>gp <Plug>(coc-diagnostic-prev)
map <Leader>gr <Plug>(coc-references)

map <Leader>rn <Plug>(coc-rename)
map <Leader>rf <Plug>(coc-refactor)
map <Leader>qf <Plug>(coc-fix-current)

map <Leader>al <Plug>(coc-codeaction-line)
map <Leader>ac <Plug>(coc-codeaction-cursor)
map <Leader>ao <Plug>(coc-codelens-action)

nnoremap <Leader>kd :<C-u>CocList diagnostics<Cr>
nnoremap <Leader>kc :<C-u>CocList commands<Cr>
nnoremap <Leader>ko :<C-u>CocList outline<Cr>
nnoremap <Leader>kr :<C-u>CocListResume<Cr>

inoremap <silent><expr> <c-space> coc#refresh()
inoremap <expr> <cr> pumvisible() ? "\<C-y>" : "\<CR>"

autocmd CursorHold * silent call CocActionAsync('highlight')
autocmd User CocJumpPlaceholder call CocActionAsync('showSignatureHelp')

CoC doesn’t set any default keybindings, so the above is mostly about defining ones.

By the way, just in case, <Leader> expands to the leader key (which is \ by default).

The names of most of these commands are pretty much self-explanatory, except perhaps these:

I also definitely recommend lowering updatetime, which affects how soon does CoC process changes in the code. I, for one, have it set to 150 (so, 150 milliseconds), and it works just fine.

Other languages

Haskell is unfortunately not the only language out there. I also do some Agda, some Idris, some C++, even some LaTeX. But, unfortunately, not all of those languages have an LSP.

What’s worse, CoC conflicts with the Agda plugin I’m using (as both use <Leader> for overlapping commands), so I just disable CoC for Agda files:

autocmd BufNewFile,BufRead *.agda execute 'CocDisable'

Similar treatment could be applied to other languages.

Managing configuration

One great thing about these “old-style” editors is that it’s straightforward to maintain, tinker with, deploy and roll back their configuration. git is my tool of choice here: I have a private dotfiles repo where I keep, among others, my .vimrc file and .vim directory, while ~/.vimrc and ~/.vim are just symlinks to these.

Maintaining plugins is also trivial: I use pathogen for injecting them into vim (no strong preference compared to other plugin managers, that’s primarily historical reasons). The plugins themselves are just git submodules of my dotfiles repo residing in the .vim/bundle dir. What if I want to add a new plugin?

cd ~/.vim/bundle
git submodule add https://github.com/plugin/name
cd ..
git commit -m "Added plugin name"

What if I want to upgrade a plugin?

cd ~/.vim/bundle/<pluginname>
git pull
cd ..
git add <pluginname>
git commit -m "Bump <pluginname>"

Bad upgrade, wanna downgrade later on? Sure, just git revert the commit!

The repo also has a tiny script for creating all the symlinks (and others), so setting up my development environment on a new machine with the configuration and all the plugins I’m used to boils down to a git clone followed by that script (and by distro-specific instructions for installing node.js for CoC).

Alternatives

vim

Why neovim instead of vim? vim seems to be more popular and more widely available after all! The only reason for me is the somewhat better support for a couple of graphical niceties.

One of them is mentioned above: it’s the neovim’s “virtual text” feature, which displays floating text (for example, with warnings or suggestions) next to “normal” code.

The other is the support for the curly underlines, which has been kind of sort of present to some extent in recent vim, but I couldn’t make it work with the terminal emulators I have. To be fair, even with neovim, I had to forego the apps I’m used to (KDE Konsole and tmux), migrating to the Kitty emulator (which supports these curlies), and avoiding any terminal multiplexers altogether. Not sure if it’s worth it.

IDEA + intellij-haskell

Here I have pretty mixed feelings.

Pros:

Cons:


All in all, I’m happy that I gave another shot to vim (and neovim), and the ecosystem has noticeably evolved over the last few years. I am pretty happy with what I have now: it’s feature-rich, it’s responsive, and it’s even quite visually appealing, at least to my eye.