NeoVim setup in Lua for OCaml development
In this post, I’ll describe how I configured NeoVim in Lua for OCaml development. The configuration will give me Nvim looking like below.
First, the followings has to be installed.
- 📝 NeoVim Installation ( NeoVim >= 0.7 should be installed.)
- 🔠 Nerd Fonts Installation script here.
- 🐫 OCaml Installation
- The following OCaml packages
- ocaml-lsp-server
- ocamlformat
- ocamlformat-rpc
opam install ocaml-lsp-server opam install ocamlformat opam install ocamlformat-rpc
Essential Lua
Lua is used for the configuration, and in this section I’ll highligh the essential Lua required for the setup.
Lua has the following types:
nil
— represents the absence of a useful valueboolean
— true and falsenumber
— double-precision floating-point numbersstring
— immutable sequence of bytestable
— associative arraysuserdata
— pointer to a block of raw memorythread
— represents independent threads of executions
'userdata'
and 'thread'
are not used in this setup.
Lua has global and local variables. 'nil'
is the default value of any non-initialized variables.
local x = 10 -- local variable
y = 10 -- here y is global variable
local z -- z is nil
Lua has 'table'
type which implements associative array. It can be used as an array or a record.
Customarily Lua array index starts at 1. But you can start an array at index 0, 1, -1 , or any other value.
local t = {} -- create an empty table
-- table as an array
local vowels = { "a", "e", "i", "o", "u" }
print(vowels[0]) --> nil
print(vowels[1]) --> "a" (array index starts at 1)
-- table as a record (key/value pairs)
local person = {
["name"] = "Pip",
["age"] = 10
}
print(person["name"]) --> "Pip"
print(person["age"]) --> 10
-- table as a class
local Point = { x = 4.2, y = 4.5 }
function Point:getX ()
return self.x
end
function Point:setX(v)
self.x = v
end
print(Point:getX()) --> 4.2
Point:setX(7.4)
print(Point:getX()) --> 7.4
-- both numeric and string index
local tbl = { "x", "y", "z", ["name"] = "Neo", ["age"] = 20 }
print(tbl[1]) --> x
print(tbl[4]) --> nil
print(tbl["name"]) --> "Neo"
Lua has a function called 'require'
which can be used to load libraries or modules.
Let’s say I have a file called 'options.lua'
, and I can import it into 'init.lua'
by calling 'require("optionss")'
inside it.
In Lua, ..
is string concatenation operator. And multi-line string is written in double square brackets.
print ("Hello," .. " World!") --> "Hello, World!"
local multiline = [[to vim
or not to vim
that's the question
]]
print (multiline)
Multi-line string is mostly used to call Vimscript from Lua like below:
vim.cmd [[
" equalalize split views
augroup _auto_resize
autocmd!
autocmd VimResized * tabdo wincmd =
augroup end
]]
'vim.cmd'
is a function, and in Lua you can call a function without parenthesis if it has only one argument which is either a literal string or a table. So 'vim.cmd "echo hello"'
is same as 'vim.cmd ("echo hello")'
or 'vim.cmd [[echo hello]]'
.
Folder structure
By default, Nvim’s user-specific configuration file is located at '~/.config/nvim/init.lua'
and is automatically loaded by Nvim everytime it starts. I have organized my configurations in the following folder structure. Basic configuration files are located in 'nvim/lua'
folder. All the plugin configurations are located in 'nvim/after/plugin'
and are automatically loaded by Nvim.
nvim
├── after
│ └── plugin
│ ├── bufferline.lua
│ ├── catppuccin.lua
│ ├── cmp.lua
│ ├── indentation.lua
│ ├── lspconfig.lua
│ ├── lualine.lua
│ ├── symbols.lua
│ ├── tree.lua
│ └── treesitter.lua
├── init.lua
├── lua
├── autocommands.lua
├── colorscheme.lua
├── disable.lua
├── maps.lua
├── options.lua
├── plugins.lua
Basic configurations
'init.lua'
loads all the modules defined in 'nvim/lua'
folder.
-- init.lua
require("disable")
require("options")
require("maps")
require("autocommands")
require("colorscheme")
require("plugins")
'options.lua'
contains Nvim option setttings. You can check out my settings at options.lua.
'disable.lua'
disables some built-in features I don’t need. More at disable.lua.
'maps.lua'
is for custom key bindings. More at maps.lua.
I define some autocommands in autocommands.lua. An autocommand executes vim actions in response to an event.
I set the theme in colorscheme.lua. I am using 'catppuccin'
theme.
Plugin manager
I use packer for plugin management. Plugin management settings are configured in 'lua/plugins.lua'
as below. You may copy the following into your 'plugins.lua'
file, save it, close and reopen Nvim, and it will install Packer. Then Packer will sync all the plugins mentioned in the function passed to 'packer.startup'
. You will see a popup dialog while Packer is installing plugins. You need to press ‘q’ to close the popup when the installation is done. Everytime you add a new plugin or remove an existing one from 'plugins.lua'
, Packer will automatically install or remove the plugin as soon as you save the file.
-- plugins.lua
local fn = vim.fn
-- Automatically install packer
local install_path = fn.stdpath("data") .. "/site/pack/packer/start/packer.nvim"
if fn.empty(fn.glob(install_path)) > 0 then
PACKER_BOOTSTRAP = fn.system({
"git",
"clone",
"--depth",
"1",
"https://github.com/wbthomason/packer.nvim",
install_path,
})
print("Installing packer close and reopen Neovim...")
vim.cmd([[packadd packer.nvim]])
end
-- Autocommand that reloads neovim whenever you save the plugins.lua file
vim.cmd([[
augroup packer_user_config
autocmd!
autocmd BufWritePost plugins.lua source <afile> | PackerSync
augroup end
]])
-- Use a protected call so we don"t error out on first use
local status_ok, packer = pcall(require, "packer")
if not status_ok then
print("Packer is not installed")
return
end
-- Have packer use a popup window
packer.init({
display = {
open_fn = function()
return require("packer.util").float({ border = "rounded" })
end,
},
})
packer.startup(function(use)
-- packer can manage itself!
use "wbthomason/packer.nvim"
-- nvim-tree file explorer
use {
"kyazdani42/nvim-tree.lua",
requires = { "kyazdani42/nvim-web-devicons" }
}
-- lsp config
use { "neovim/nvim-lspconfig", tag = "v0.1.3" }
-- colorscheme
use { "catppuccin/nvim", as = "catppuccin" }
-- autocompletion
use "hrsh7th/nvim-cmp"
use "hrsh7th/cmp-nvim-lsp"
use "hrsh7th/cmp-buffer"
use "saadparwaiz1/cmp_luasnip"
-- snippet
use "L3MON4D3/LuaSnip"
-- symbol
use "simrat39/symbols-outline.nvim"
-- statusbar
use {
"nvim-lualine/lualine.nvim",
requires = { "kyazdani42/nvim-web-devicons", opt = true }
}
-- bufferline
use { "akinsho/nvim-bufferline.lua", tag = "v2.7.0" }
-- treesitter
use {
"nvim-treesitter/nvim-treesitter",
run = function() require("nvim-treesitter.install").update({ with_sync = true }) end,
}
-- indentation guides
use "lukas-reineke/indent-blankline.nvim"
-- Automatically run packer.clean() followed by packer.update() after cloning packer.nvim
-- Put this at the end after all plugins
if PACKER_BOOTSTRAP then
require("packer").sync()
end
end)
Nvim-tree, Lualine, Bufferline
I am using 'nvim-tree'
for file explorer, 'lualine'
for status bar, and 'bufferline'
for showing opening buffer names and buffer/tab management. My configurations for them can be found at nvim-tree, lualine, bufferline. You may also have a look at my catppuccin theme configuration for integration with nvim-tree
and bufferline
. Based on my configuration, i will get Nvim looking like this.
Configuring Language Server Client
Nvim provides a language server protocol (LSP) client. I use 'lspconfig'
plugin to configure the client to consume services from OCaml language server. Basically the setup is something like below. You may check out the complete configuration of my lspconfig here.
-- lspconfig.lua
local status, lsp = pcall(require, "lspconfig")
if (not status) then return end
local on_attach = function(client, bufnr)
-- enable completion triggered by <C-x><C-o>
vim.api.nvim_buf_set_option(bufnr, "omnifunc", "v:lua.vim.lsp.omnifunc")
local bufopts = { noremap=true, silent=true, buffer=bufnr }
vim.keymap.set("n", "gD", vim.lsp.buf.declaration, bufopts)
vim.keymap.set("n", "gd", vim.lsp.buf.definition, bufopts)
vim.keymap.set("n", "K", vim.lsp.buf.hover, bufopts)
vim.keymap.set("n", "gi", vim.lsp.buf.implementation, bufopts)
vim.keymap.set("n", "gk", vim.lsp.buf.signature_help, bufopts)
vim.keymap.set("n", "<space>wa", vim.lsp.buf.add_workspace_folder, bufopts)
vim.keymap.set("n", "<space>wr", vim.lsp.buf.remove_workspace_folder, bufopts)
vim.keymap.set("n", "<space>wl", function()
print(vim.inspect(vim.lsp.buf.list_workspace_folders()))
end, bufopts)
vim.keymap.set("n", "<space>td", vim.lsp.buf.type_definition, bufopts)
vim.keymap.set("n", "<space>rn", vim.lsp.buf.rename, bufopts)
vim.keymap.set("n", "<space>ca", vim.lsp.buf.code_action, bufopts)
vim.keymap.set("n", "gr", vim.lsp.buf.references, bufopts)
vim.keymap.set("n", "<space>f", vim.lsp.buf.formatting, bufopts)
vim.keymap.set("n", "<space>do", vim.diagnostic.open_float, bufopts)
vim.keymap.set("n", "gp", vim.diagnostic.goto_prev, bufopts)
vim.keymap.set("n", "gl", vim.diagnostic.goto_next, bufopts)
vim.keymap.set("n", "<space>dl", vim.diagnostic.setloclist, bufopts)
-- format on save
if client.server_capabilities.documentFormattingProvider then
vim.api.nvim_create_autocmd("BufWritePre", {
group = vim.api.nvim_create_augroup("Format", { clear = true }),
buffer = bufnr,
callback = function() vim.lsp.buf.formatting_seq_sync() end
})
end
-- code lens
if client.resolved_capabilities.code_lens then
local codelens = vim.api.nvim_create_augroup(
'LSPCodeLens',
{ clear = true }
)
vim.api.nvim_create_autocmd({ 'BufEnter', 'InsertLeave', 'CursorHold' }, {
group = codelens,
callback = function()
vim.lsp.codelens.refresh()
end,
buffer = bufnr,
})
end
end
local c = vim.lsp.protocol.make_client_capabilities()
c.textDocument.completion.completionItem.snippetSupport = true
c.textDocument.completion.completionItem.resolveSupport = {
properties = {
'documentation',
'detail',
'additionalTextEdits',
},
}
local capabilities = require("cmp_nvim_lsp").update_capabilities(c)
lsp.ocamllsp.setup({
cmd = { "ocamllsp" },
filetypes = { "ocaml", "ocaml.menhir", "ocaml.interface", "ocaml.ocamllex", "reason", "dune" },
root_dir = lsp.util.root_pattern("*.opam", "esy.json", "package.json", ".git", "dune-project", "dune-workspace"),
on_attach = on_attach,
capabilities = capabilities
})
With the configuration, I would get Nvim looking like below when i open an OCaml project.
Autocomplete
I am using 'nvim-cmp'
for autocompletion. 'nvim-cmp'
needs a snippet source otherwise it might throw error sometimes. I use 'luasnip'
for snippet engine and you might notice it’s already added into 'plugins.lua'
file. The following is the configuration of 'nvim-cmp'
. You may also have a look at cmp.lua.
-- cmp.lua
local status, cmp = pcall(require, "cmp")
if (not status) then return end
local luasnip = require("luasnip")
local lsp = require("lspconfig")
local has_words_before = function()
local line, col = unpack(vim.api.nvim_win_get_cursor(0))
return col ~= 0 and vim.api.nvim_buf_get_lines(0, line - 1, line, true)[1]:sub(col, col):match("%s") == nil
end
local cmp_kinds = {
Text = "",
Method = "",
Function = "",
Constructor = "",
Field = "",
Variable = "",
Class = "ﴯ",
Interface = "",
Module = "",
Property = "ﰠ",
Unit = "",
Value = "λ",
Enum = "",
Keyword = "",
Snippet = "",
Color = "",
File = "",
Reference = "",
Folder = "",
EnumMember = "",
Constant = "",
Struct = "",
Event = "",
Operator = "",
TypeParameter = ""
}
cmp.setup({
snippet = {
expand = function(args)
luasnip.lsp_expand(args.body)
end,
},
window = {
completion = {
winhighlight = "Normal:Pmenu,FloatBorder:Pmenu,Search:None",
},
documentation = {
border = { "╭", "─", "╮", "│", "╯", "─", "╰", "│" },
},
},
mapping = cmp.mapping.preset.insert({
["<C-k>"] = cmp.mapping.scroll_docs(-4),
["<C-j>"] = cmp.mapping.scroll_docs(4),
["<C-e>"] = cmp.mapping.abort(),
["<CR>"] = cmp.mapping.confirm({
behavior = cmp.ConfirmBehavior.Replace,
select = true
}),
["<Tab>"] = cmp.mapping(function(fallback)
if cmp.visible() then
cmp.select_next_item()
elseif luasnip.expand_or_jumpable() then
luasnip.expand_or_jump()
elseif has_words_before() then
cmp.complete()
else
fallback()
end
end, { "i", "s" }),
["<S-Tab>"] = cmp.mapping(function(fallback)
if cmp.visible() then
cmp.select_prev_item()
elseif luasnip.jumpable(-1) then
luasnip.jump(-1)
else
fallback()
end
end, { "i", "s" }),
}),
sources = cmp.config.sources({
{ name = "nvim_lsp" },
{ name = "luasnip" },
{ name = "buffer" },
option = {
get_bufnrs = function()
local bufs = {}
for _, win in ipairs(vim.api.nvim_list_wins()) do
bufs[vim.api.nvim_win_get_buf(win)] = true
end
return vim.tbl_keys(bufs)
end
}
}),
formatting = {
fields = { "kind", "abbr", "menu" },
format = function(entry, vim_item)
vim_item.kind = cmp_kinds[vim_item.kind] or ""
local lsp_icon = "🅻"
if lsp ~= nil and lsp.ocamllsp ~= nil then
lsp_icon = "🐫"
end
vim_item.menu = ({
buffer = "🅱",
nvim_lsp = lsp_icon,
luasnip = "㊊"
})[entry.source.name]
return vim_item
end,
}
})
With the above configuration, autocompletion will look like below:
Treesitter, Indentation, and Symbols
I am using 'treesitter'
for better syntax highlighting. You may use the following configuration for it. After saving the config, you need to close and reopen Nvim to install OCaml parser.
-- config file name: tree.lua
local status, ts = pcall(require, "nvim-treesitter.configs")
if (not status) then return end
ts.setup {
highlight = {
enable = true,
disable = {},
},
indent = {
enable = true,
disable = {},
},
ensure_installed = {
"ocaml",
},
sync_install = true,
autotag = {
enable = true,
},
rainbow = {
enable = true,
extended_mode = true,
max_file_lines = nil,
},
autopairs = {
enable = true,
}
}
For indentation line and symbol outline, i use 'indent-blankline'
and 'symbols-outline'
plugin. Please check out indentation.lua and symbols.lua for the configuration. The configruations give me Nvim looking like this:
The GitHub repo related to this post is here.
Or If you want to check out my Nvim setup for OCaml, Rust, Rescript, JavaScript, TypeScript, and TailwindCSS, please have a look at this.
Thank you for reading the post.