Neovim

Neovim configuration

.PHONY: nvim
nvim:
     test -L $(HOME)/.config/nvim || ln -s $(shell pwd)/nvim $(HOME)/.config/nvim

It’s been some time since I have used (n)vim as my primary editor, certainly pre nvim v0.5 (nvim v0.10.1 is now available at the time of writing).

Needless to say a lot has changed (such as using lua for configuration!) so the overall structure of this config has been heavily influenced by/stolen from nvim-lua/kickstart.nvim

Basic Options

Appearance

For the time being, use the default colorscheme with notermguicolors set so that nvim inherits the colorscheme used by the terminal. (Makes adapting to the system theme easier)

vim.opt.termguicolors = false

breakindent

vim.opt.breakindent = true

It ensures that when a long that is indented is wrapped, the next line starts at the same level of indentation i.e

|   This is a very
|   long line that
|   has been wrapped

Instead of

|   This is a very
|long line that
|has been wrapped

Searching

Disabling ignorecase will make searches case sensitive by default. To make the search case insensitive add a \c to the search pattern e.g. /set\c/ or /\cset/

The inccomand = 'split' tells neovim to open a dedicated split to preview the result of the current :%s/../../ command.

vim.opt.inccommand = 'split'
vim.opt.incsearch = true
vim.opt.ignorecase = false

Line Numbers

Enable line numbers

vim.opt.number = true

Reuse the line number column to render ‘signs’

vim.opt.signcolumn = 'number'

Whitespace

Render certain whitespace characters

vim.opt.list = true
vim.opt.listchars = { tab = '».', trail = '·', extends = '→', precedes= '←' }

Keybindings

Keybindings that are useful anywhere.

Set the <leader> and <localleader>

vim.g.mapleader = ' '
vim.g.maplocalleader = ' '

Mash Esc to clear any search highlights

vim.keymap.set('n', '<Esc>', '<cmd>nohlsearch<CR>')

Easier movement between windows

vim.keymap.set('n', '<C-h>', '<C-w><C-h>')
vim.keymap.set('n', '<C-l>', '<C-w><C-l>')
vim.keymap.set('n', '<C-j>', '<C-w><C-j>')
vim.keymap.set('n', '<C-k>', '<C-w><C-k>')

Recenter the display after common movement commands

vim.keymap.set('n', 'n', 'nzz')
vim.keymap.set('n', 'N', 'Nzz')
vim.keymap.set('n', 'G', 'Gzz')
vim.keymap.set('n', '<c-i>', '<c-i>zz')
vim.keymap.set('n', '<c-o>', '<c-o>zz')

Plugins

The plugin manager du-jour appears to be folke/lazy.nvim, let’s ensure that it’s available.

local lazypath = vim.fn.stdpath('data') .. '/lazy/lazy.nvim'
if not vim.uv.fs_stat(lazypath) then
  local lazyrepo = 'https://github.com/folke/lazy.nvim.git'
  local out = vim.fn.system({ 'git', 'clone', '--filter=blob:none', '--branch=stable', lazyrepo, lazypath})

  if vim.v.shell_error ~= 0 then
    error('Error cloning lazy.nvim:\n' .. out)
  end
end

And add the install location to the runtime path

vim.opt.rtp:prepend(lazypath)

Finally, tell lazyvim what plugins to install and configure

require('lazy').setup({
  'tpope/vim-sleuth',
  require 'alc.completion',
  require 'alc.lsp',
  require 'alc.telescope',
})

Completion

hrsh7th/nvim-cmp seems to be the completion framework of choice

function setup()
  local cmp = require('cmp')

  cmp.setup {
    completion = { completeopt = 'menu,menuone,noinsert' },
    mapping = cmp.mapping.preset.insert {
       ['<C-n>'] = cmp.mapping.select_next_item(),
       ['<C-p>'] = cmp.mapping.select_prev_item(),
       ['<C-b>'] = cmp.mapping.scroll_docs(-4),
       ['<C-f>'] = cmp.mapping.scroll_docs(4),
       ['<C-y>'] = cmp.mapping.confirm { select = true },
     },
    sources = {
       { name = 'nvim_lsp' },
     },
  }
end
return {
  'hrsh7th/nvim-cmp',
  event = 'InsertEnter',
  dependencies = {
    'hrsh7th/cmp-nvim-lsp',
  },
  config = setup
}

Language Servers

The following is a function that will be called each time a connection to a server is made, allowing LSP specific keybindings etc to be configured.

function lsp_attach(event)
  vim.keymap.set('n', 'gd', require('telescope.builtin').lsp_definitions, { buffer = event.buf })
  vim.keymap.set('n', 'gr', require('telescope.builtin').lsp_references, { buffer = event.buf })
  vim.keymap.set('n', 'gI', require('telescope.builtin').lsp_implementations, { buffer = event.buf })
  vim.keymap.set('n', '<leader>D', require('telescope.builtin').lsp_type_definitions, { buffer = event.buf })
  vim.keymap.set('n', '<leader>ds', require('telescope.builtin').lsp_document_symbols, { buffer = event.buf })
  vim.keymap.set('n', '<leader>ws', require('telescope.builtin').lsp_dynamic_workspace_symbols, { buffer = event.buf })
  vim.keymap.set('n', '<leader>rn', vim.lsp.buf.rename, { buffer = event.buf })
  vim.keymap.set('n', '<leader>ca', vim.lsp.buf.code_action, { buffer = event.buf })
  vim.keymap.set('n', 'gD', vim.lsp.buf.declaration, { buffer = event.buf })
end

Finally, the block of lazy.nvim configuration that ties it all together

return {
   'neovim/nvim-lspconfig',
   dependencies = {
     'hrsh7th/cmp-nvim-lsp',
     { 'j-hui/fidget.nvim', opts = {
         notification = {
           override_vim_notify = true,
         }
       }
     },
   },
   config = function()
     vim.api.nvim_create_autocmd('LspAttach', {
       group = vim.api.nvim_create_augroup('alc-lsp-attach', { clear = true }),
       callback = lsp_attach
     })

     -- Ensure the additional capabilities provided by nvim-cmp are provided to the server
     local capabilities = vim.lsp.protocol.make_client_capabilities()
     capabilities = vim.tbl_deep_extend('force', capabilities, require('cmp_nvim_lsp').default_capabilities())

     -- Setup each server
     local use_devtools = os.getenv('LSP_DEVTOOLS') ~= nil
     require('alc.lsp.esbonio').setup { capabilities = capabilities, devtools = use_devtools }
     require('alc.lsp.python').setup { capabilities = capabilities }
   end
}

esbonio

Of course, I use swyddfa/esbonio with my documentation projects.

function setup(opts)
   opts = opts or {}

   if not opts.capabilities then
     opts.capabilities = vim.lsp.protocol.make_client_capabilities()
   end

   require('lspconfig').esbonio.setup {
     cmd = get_esbonio_cmd(opts),
     capabilities = capabilities,
     handlers = {
       ["sphinx/clientCreated"] = client_created,
       ["sphinx/appCreated"] = app_created,
       ["sphinx/clientErrored"] = client_errored,
       ["sphinx/clientDestroyed"] = client_destroyed,
     },
     commands = {
       EsbonioPreviewFile = {
         preview_file,
         description = 'Preview Current File',
       },
     },
     settings = {
       esbonio = {
         logging = {
           level = 'debug',
           window = opts.devtools or false,
         }
       }
     }
   }
end

The following function determines the command that should be used to launch esbonio, useful for the occasions where I need to debug it or use some of the swyddfa/lsp-devtools tooling.

Note

Until I can configure nvim to send URIs relative to the devcontainer e.g.

file:///var/home/alex/Projects/alcarney/blog -> file:///workspaces/blog

it is pointless trying to run an LSP from inside the container.

function get_esbonio_cmd(opts)
  local cmd = {}

  -- TODO: Devcontainer support.
  --
  -- local workspace = require('alc.devcontainer').workspace()
  -- if workspace then
  --   table.insert(cmd, 'devcontainer')
  --   table.insert(cmd, 'exec')
  --   table.insert(cmd, '--workspace-folder')
  --   table.insert(cmd, workspace)
  -- end

  if opts.devtools then
    table.insert(cmd, 'lsp-devtools')
    table.insert(cmd, 'agent')
    table.insert(cmd, '--')
  end

  table.insert(cmd, 'esbonio')
  return cmd
end

Previews

The following command triggers a preview of the file vistied by the current buffer as well as setting up an autocommand to syncronise the scroll state with the preview.

function preview_file()
  local params = {
    command = 'esbonio.server.previewFile',
    arguments = {
      { uri = vim.uri_from_bufnr(0), show = true },
    },
  }

  local clients = require('lspconfig.util').get_lsp_clients {
    bufnr = vim.api.nvim_get_current_buf(),
    name = 'esbonio',
  }
  for _, client in ipairs(clients) do
    client.request('workspace/executeCommand', params, nil, 0)
  end

  local augroup = vim.api.nvim_create_augroup("EsbonioSyncScroll", { clear = true })
  vim.api.nvim_create_autocmd({"WinScrolled"}, {
    callback = scroll_view, group = augroup, buffer = 0
  })
end

Scrolling the view itself is handled by the following function

function scroll_view(event)
  local esbonio = vim.lsp.get_active_clients({ bufnr = 0, name = 'esbonio' })[1]
  local view = vim.fn.winsaveview()

  local params = { uri = vim.uri_from_bufnr(0),  line = view.topline }
  esbonio.notify('view/scroll', params)
end

Sphinx Processes

These handlers help shed some light on the status of the underlying sphinx processes managed by esbonio.

sphinx/clientCreated

Emitted when a new process is created.

function client_created(err, result, ctx, config)
  vim.notify("Sphinx client created in " .. result.scope, vim.log.levels.INFO)
end

function app_created(err, result, ctx, config)
  vim.notify("Application created", vim.log.levels.INFO)
end

function client_errored(err, result, ctx, config)
  vim.notify("Client error: " .. result.error, vim.log.levels.ERROR)
end

function client_destroyed(err, result, ctx, config)
  vim.notify("Sphinx client destroyed", vim.log.levels.INFO)
end
return {
  setup = setup
}

Python

I currently use the microsoft/pyright language server for Python projects.

function setup(opts)
   opts = opts or {}

   if not opts.capabilities then
     opts.capabilities = vim.lsp.protocol.make_client_capabilities()
   end

   require('lspconfig').pyright.setup {
     capabilities = capabilities,
     settings = {
       python = {
       }
     }
   }
end
return {
  setup = setup
}

Telescope

The vertico of the neovim world, telescope offers a nice “select item from list” UI

return {
  'nvim-telescope/telescope.nvim',
  event = 'VimEnter',
  branch = '0.1.x',
  dependencies = {
    'nvim-lua/plenary.nvim',
    'nvim-telescope/telescope-ui-select.nvim',
  },
  config = function()
    local themes = require('telescope.themes')

    require('telescope').setup {
      defaults = themes.get_ivy(opts)
    }

    pcall(require('telescope').load_extension, 'ui-select')

    local builtin = require 'telescope.builtin'
    vim.keymap.set('n', '<leader>ff', builtin.find_files)
  end
}

Libraries & Helpers

What follows is a series of helper libraries I’ve written to support the configuration above.

alc.devcontainer

Thanks to switching to ublue-os/bluefin (well, Aurora) I’m finally got a setup where devcontainers work for me. This helper library helps me make use of the devcontainers/cli from within nvim.

The following function will check to see if there is a .devcontainer/ for the repo and return the relevant workspace folder if it exists.

function workspace()
  local cwd = vim.uv.cwd()
  if not cwd then
    return nil
  end

  local repo = require('lspconfig.util').find_git_ancestor(cwd)
  if not repo then
    return nil
  end

  local devcontainer = vim.fs.joinpath(repo, '.devcontainer')
  if not vim.uv.fs_stat(devcontainer) then
    return nil
  end

  return repo
end

Finally, export all the public functions from this module

return {
  workspace = workspace
}