(neo)vim and Haskell, 2021 edition
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:
- Autocompletion.
- Checking code for errors, warnings, and hints as you type, with quick-fixes:
- Renaming. It supports all the stuff you’d expect: bindings, constructors, types, classes, whatnot.
The upside is that it even works across modules:
The downside is that it only works across modules within a single package. If you rename something in your library module, other modules in the library package will also get updated, but the executables, tests, and benchmarks won’t. Also, note that for now, the renaming plugin needs to be explicitly enabled when buildinghls
. - Code navigation (that is, jumping to a term’s definition).
- Showing function types and Haddock documentation.
- A ton of code actions.
Forgot something in the import list or the import list entirely?
Forgot a pragma?
Want to generate function documentation comments? No prob!
What’s missing or broken?
- Default quickfixes on some errors:
Come on, man! If I wanted a fix like this, I’d be programming Python or JavaScript! - Semantic highlighting — basically using different colors for different variables. I guess there must be a plugin for that, but I never bothered enough to figure this out, even though this is a desirable feature.
What I don’t care about, and hence don’t know if it works or not?
- Code folding.
- Code autoformatters. I guess this one must work since it’s mentioned so much in the docs, but I just never cared enough to give it a try.
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:
- The
suggest
group settings control the presentation of the autocompletion list. The stuff I have here merely disables some symbology I find irrelevant. YMMV. - The
diagnostic
group enables and configures a pretty cool (but neovim-only) feature that displays errors and warnings to the right of the actual source code without having to hover over it:
- The
codeLens.enable
thing enables, well, the code lens, which I find most useful for two things:- Inline code evaluation, which I’ve shown in the intro.
- Showing the inferred type of functions without type annotations:
coc.preferences.enableMarkdown
tells CoC that the documentation snippets returned by the language server have Markdown.coc.preferences.jumpCommand
tells CoC how to open new tabs on jump commands like “go to definition.”
(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:
<Leader>ggd
jumps to the definition of the symbol under the cursor.<Leader>gh
shows documentation for the entity under the cursor.<Leader>qf
applies the most relevant quickfix (inCoC
/hls
’ opinion).
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:
- It just works™ (for the most part).
- The maintainer of intellij-haskell is very responsive, and I had a chance to see that for myself. I was missing case-splitting pattern variables (like one does in Idris or Agda), which I requested, and he implemented it in a week or two.
- It was much more stable.
Just a year or two ago, when
hls
wasn’t a thing yet, andhie
was semi-constantly semi-broken, it was the only way to get a setup that allows for several projects with different major Stackage LTS versions, that doesn’t break too often on LTS bumps, and so on. Alas, now that’s way less relevant. - intellij-haskell can even jump into definitions of functions defined in the libraries the project’s using. That looks like a minor thing, but it’s beneficial in some cases.
Cons:
- Resources consumption.
I have several projects where IDEA consumes all the Java heap.
What’s worse, I had too many occasions when the whole IDEA freezes for 0.5-2 seconds as the Java GC kicks in,
reclaiming almost all of that memory, only to fill it up in a matter of seconds and repeat the cycle.
Increasing the Java heap limit doesn’t help much: it just eats more RAM.
Frankly, this was the only reason that made me give another shot to a vim-based workflow,
this time with
CoC
andhls
, and this alone is sufficient to switch away. - It seems that the way intellij-haskell works is by running
stack build --fast
under the hood more or less on every file save. I usually don’t care about that, but if I’m working on the performance of my code, I need optimized builds. Effectively, this means that the project gets recompiled from scratch every time I need to build it for my benchmarks. If I were less lazy, that’d be the second reason sufficient to investigate alternatives. - Keeping track of IDE settings is non-trivial. There’s effectively no way to put that stuff in git.
- While upgrading plugins is just as easy (well, it’s a matter of just pressing the Update button), reverting them in case things break (and they do break occasionally) is non-trivial.
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.