Neovim: Tips and tricks for OCaml's LSP server

When developing OCaml projects in Neovim, the OCaml language server (ocamllsp) can be of great help. It should just work out of the box, however there are few tweaks you can do in order to improve your developer experience even more.

First things first: Setting everything up

I'm assuming you have a working OCaml and opam environment. If not, follow the steps on the official "Getting Started" page first.

After that, let's make sure all the necessary packages are installed:

$ opam install ocaml-lsp-server ocamlformat dune

This will install the following packages globally

In order to simplify the server configuration, we're using nvim-lspconfig. Make sure to follow your package manager's instructions on how to add lspconfig to your Neovim installation. Once this is done, add a basic configuration for ocamllsp somewhere in your Lua config files:

local lsp_config = require('lspconfig')
lsp_config['ocamllsp'].setup({})

Once you've reloaded your configuration, you should have a working LSP integration while editing OCaml source files.

Bonus: LSP keybindings

Last but not least, if you have not configured your LSP keybindings so far, feel free to use those as your inspiration:

vim.api.nvim_create_autocmd('LspAttach', {
    group = vim.api.nvim_create_augroup('UserLspConfig', {}),
    callback = function(event)
        local opts = { buffer = event.buf }
        vim.keymap.set('n', 'gD', vim.lsp.buf.declaration, opts)
        vim.keymap.set('n', 'gd', vim.lsp.buf.definition, opts)
        vim.keymap.set('n', 'gt', vim.lsp.buf.type_definition, opts)
        vim.keymap.set('n', 'K', vim.lsp.buf.hover, opts)
        vim.keymap.set('n', 'gi', vim.lsp.buf.implementation, opts)
        vim.keymap.set('n', '<C-k>', vim.lsp.buf.signature_help, opts)
        vim.keymap.set('n', '<leader>wa', vim.lsp.buf.add_workspace_folder, opts)
        vim.keymap.set('n', '<leader>wr', vim.lsp.buf.remove_workspace_folder, opts)
        vim.keymap.set('n', '<leader>wl', function()
            print(vim.inspect(vim.lsp.buf.list_workspace_folders()))
        end, opts)
        vim.keymap.set('n', '<leader>H', vim.lsp.buf.typehierarchy, opts)
        vim.keymap.set('n', '<leader>rn', vim.lsp.buf.rename, opts)
        vim.keymap.set({ 'n', 'v' }, '<leader>ca', vim.lsp.buf.code_action, opts)
        vim.keymap.set('n', 'gr', vim.lsp.buf.references, opts)
        vim.keymap.set('n', '<leader>fo', function()
            vim.lsp.buf.format { async = true }
        end, opts)
    end,
})

Dune RPC: keeping LSP information up to date

Once you started using the LSP server, you may notice that from time to time diagnostic information are not up to date. This often happens when new files or modules are added the project. One way to solve those issues is to build the project with dune build followed by :LspRestart in Neovim. This will ensure that the latest build information are available to ocamllsp.

However, there is a much better way by utilizing the RPC mechanism of dune. Newer versions of dune will start an (experimental) RPC server when a "watch" build is started. ocamllsp can utilize this RPC mechanism to get notifications about new build information and update the editor diagnostics accordingly.

There is configuration for ocamllsp needed, since it should recognize the running dune process and automatically establish the RPC connection. The only thing to do is to start a continuous build of your project via the -w flag before the LSP server is started (e.g. dune build -w or dune exec -w).

Running Dune in a separate build directory

Correction: A previous version of this article stated that ocamllsp will work out of the box if the continuous dune build is running in a different build folder. This is not correct, as it needs additional configuration as stated below.

See also this GitHub issue for more information.

While the continuous dune build gives up-to-date diagnostics of the project, there is one annoying downside - it will lock your build directory for other dune processes. I.e. you won't be able to execute dune exec or dune runtest while the build process is running. You could obviously stop the build, run your other commands and then restart the build, but this gets annoying fast.

Another way is to use different build directories - one for your continuous dune builds which provide realtime diagnostics to ocamllsp - and the default directory to execute tests and programs. Dune supports this via the --build-dir=<path> option. E.g. to execute the dune instance for ocamllsp in the _build_lsp folder, run

$ dune build -w --build-dir=_build_lsp

In order for ocamllsp to recognize non-standard build directories, the DUNE_BUILD_DIR environment variable needs to be set accordingly. In lspconfig, this can be achieved by adding the cmd_env config parameter to the ocamllsp entry

lsp_config['ocamllsp'].setup({
    cmd_env = {DUNE_BUILD_DIR = '_build_lsp'},
    --- ...
})

This will ensure that one Dune instance is running builds in _build_lsp and provides up to date diagnostics to ocamllsp via RPC, while you can still execute tests and run your OCaml programs simultaneously in the default build directory.

Help! My files are not formatting

If the LSP format command is not doing anything, the most likely reason is a missing .ocamlformat file. Without it, the ocamlformat tool will not change any files. To solve the issue, simply add a .ocamlformat file at the root of your project (even an empty one should do). For more details on the formatting options available via this file, run man ocamlformat or check the online documentation.