Viming Haskell in 2021

Introduction

This post describes my Haskell/Vim setup for work and fun. The full setup is available on github.

Language Server Protocol

I use neovim because it has a built-in implementation of Language Server Protocol (LSP). It might not be as good as the other solutions such as these plug-ins: ALE, Coc, vim-lsp. Actually I have no idea. But I tend to prefer built-in features. Firstly because it makes my experience more consistent when I switch between machines. And also I think it will have more chance to get maintained in the long run so won’t have to switch to another solution at some point.

Neovim has a built-in implementation of LSP since its version 5.x. As the time of writing it is not released yet but it is possible to use one of the nightly versions available on various places (snap, github nightly binaries, AUR, etc…).

As for me, I use NixOS and I have an overlay in ~/.config/nixpkgs/overlays/neovim.nix

self: super: {
  neovim-unwrapped = super.neovim-unwrapped.overrideAttrs (oldAttrs: {
    version = "nightly";
    src = super.fetchFromGitHub {
      owner = "neovim";
      repo = "neovim";
      rev = "nightly";
      sha256 = super.lib.fakeSha256;
    };
    buildInputs = oldAttrs.buildInputs ++ [ super.tree-sitter ];
  });
}

Install it with:

nix-env -iA nixos-unstable.neovim

It will fail and complain about the bad hash but it will give you the right hash to put in the attribute sha256.

installing 'neovim-nightly'
these derivations will be built:
  /nix/store/ciwsbv9av383j9dq04r761088wkj1sm2-source.drv
  /nix/store/kfz14kflipjyh10lwmcs5lfc7yw4yflw-neovim-unwrapped-nightly.drv
  /nix/store/82a9s16dxgxr2001gzqzcjz5kl7kpjn4-neovim-nightly.drv
building '/nix/store/ciwsbv9av383j9dq04r761088wkj1sm2-source.drv'...

trying https://github.com/neovim/neovim/archive/nightly.tar.gz
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   122  100   122    0     0    450      0 --:--:-- --:--:-- --:--:--   448
100 9839k    0 9839k    0     0  1392k      0 --:--:--  0:00:07 --:--:-- 1568k
unpacking source archive /build/nightly.tar.gz
hash mismatch in fixed-output derivation '/nix/store/qkx75z9mbnpq8rm88mk56kgavamk3ar5-source':
  wanted: sha256:0000000000000000000000000000000000000000000000000000
  got:    sha256:0f9g33vjfp1dhgm6skyvf6qgr2z4anwl5pz1dqp55fpkh8r1a0ws
cannot build derivation '/nix/store/kfz14kflipjyh10lwmcs5lfc7yw4yflw-neovim-unwrapped-nightly.drv': 1 dependencies couldn't be built
cannot build derivation '/nix/store/82a9s16dxgxr2001gzqzcjz5kl7kpjn4-neovim-nightly.drv': 1 dependencies couldn't be built
error: build of '/nix/store/82a9s16dxgxr2001gzqzcjz5kl7kpjn4-neovim-nightly.drv' failed

So replace the hash and restart the command and it should work this time.

Configuration

Even if LSP is built-in in neovim, it still requires a few external plug-ins to make the experience simple and straightforward.

call plug#begin('~/.vim/plugged')
...
if has('nvim-0.5.0')
    " Good default for many languages
    Plug 'neovim/nvim-lspconfig'
    " Autocompletion framework for LSP
    Plug 'nvim-lua/completion-nvim'
endif
...
call plug#end()

I use guard around these plug-ins to make my configuration compatible with vanilla vim.

And here is the main configuration in lua.

" Activate language servers on neovim
if has('nvim-0.5.0')
lua << EOF
  local nvim_lsp = require('lspconfig')

  local on_attach = function(client)
      -- Activate completion
      require'completion'.on_attach(client)

      -- Mappings
      local opts = { noremap=true }
      vim.api.nvim_buf_set_keymap(0, 'n', '<c-]>',
          '<Cmd>lua vim.lsp.buf.definition()<CR>', opts)
      vim.api.nvim_buf_set_keymap(0, 'n', 'K',
          '<Cmd>lua vim.lsp.buf.hover()<CR>', opts)
      vim.api.nvim_buf_set_keymap(0, 'n', 'gd',
          '<Cmd>lua vim.lsp.buf.declaration()<CR>', opts)
      vim.api.nvim_buf_set_keymap(0, 'n', 'gD',
          '<cmd>lua vim.lsp.buf.implementation()<CR>', opts)
      vim.api.nvim_buf_set_keymap(0, 'n', 'gr',
          '<cmd>lua vim.lsp.buf.references()<CR>', opts)

      vim.api.nvim_buf_set_keymap(0, 'n', '<leader>ca',
          '<cmd>lua vim.lsp.buf.code_action()<CR>', opts)
      vim.api.nvim_buf_set_keymap(0, 'n', '<leader>cr',
          '<cmd>lua vim.lsp.buf.rename()<CR>', opts)
      vim.api.nvim_buf_set_keymap(bufnr, 'n', '<leader>ls',
          '<cmd>lua vim.lsp.diagnostic.show_line_diagnostics()<CR>', opts)

      vim.api.nvim_buf_set_keymap(0, 'i', '<C-s>',
          '<cmd>lua vim.lsp.buf.signature_help()<CR>', opts)

      -- autoformat only for haskell
      if vim.api.nvim_buf_get_option(0, 'filetype') == 'haskell' then
          vim.api.nvim_command[[
              autocmd BufWritePre <buffer> lua vim.lsp.buf.formatting_sync()]]
      end
  end

  nvim_lsp.hls.setup({
      on_attach = on_attach,
      settings = {
          haskell = {
              hlintOn = true,
              formattingProvider = "fourmolu"
          }
       }
  })

EOF
endif

Basically what it does is activate Haskell Language Server, turn on some options and set shortcuts.

Usage

Here is the result:

The LSP should start as soon as you open a Haskell file assuming that you have haskell-language-server in your path.

The main shortcuts I use are:

  • K: to show the type of the symbol under the cursor
  • CRTL-]: to go to the definition of the symbol
  • <leader>-ca: code action. This one is very useful. Type it on an undefined symbol and it will propose you to add the import line automatically, same applies for extensions. It can also fill up type holes in some cases, write inferred types of top level functions, and even apply hlint hints.

Handy things to know

To find out if LSP is running:

:lua print(vim.inspect(vim.lsp.buf_get_clients()))

It should output a big JSON object. If it returns {} that means that LSP is not running for the current buffer. To find out what is going on, it is sometimes useful to run the language server from the command line and read its output. Very often it is a missing or bad hie.yaml file for the project. Find out more about hie.yaml files on the README.md of haskell-language-server.

Sometimes the server crashes. Restarting it is just a matter of opening the file again, so :e should do it.

In my configuration, I also have a status line activated. It is not a mandatory thing to have. But sometimes haskell-language-server can take a long time to initialize and this status line shows its progression so I know the server is not crashed and is just initializing.

Ghcid

LSP helps me mainly to remember the types of the symbols and to browse the source. But for compilation, I rely heavily on ghcid. This tool runs a command that starts ghci, like cabal repl or stack repl and when the source code has changed it triggers a :r and show you the first error. The only one we actually care about.

It’s got a neovim plugin which works quite well out of the box and loads the errors in the quicklist.

Conclusion

With haskell-language-server it has never been so easy to hack in Haskell in no time. I’m very impressed on how it changed my workflow and helped me to get more productive. I’m looking forward to seeing which features new versions of haskell-language-server will bring.

May 14, 2021