For a long time, I'd been hearing about null_ls on the neovim subreddit and how it solves all my non-language-server tooling problems.

I had given a brief look at null_ls a while back, and didn't really comprehend what it wanted of me. I didn't understand how my neovim config applied, and how I setup my language servers would be applicable or not.

It wasn't until yesterday when I was playing around with custom root_dir functions on tsserver and glint language server.

Normal language servers are set up by requiring lspconfig and calling the appropriate server's setup method:

local lsp = require('lspconfig')

lsp[serverName].setup({   
  -- config here
})

and null_ls is similar:

local null_ls = require('null-ls')

null_ls.setup({
 -- config here
})

and that's the whole gist 🎉.

Of course, just being able to call setup isn't enough, I wanted the following features:

  • eslint
    • diagnostics and code-actions
    • run on save
  • prettier
    • diagnostics and code-actions
    • run on save
  • spell check diagnostics and actions
    • a single machine-global file to store my custom words
    • I had lost this when I switched away from CoC

It turns out that all of these integrations are built-in to null_ls!!

As I was browsing for which built-in configs would work best for me, I learned that both prettier and eslint have daemon versions, eslint_d and prettierd. This is important to me because I want my editor to be as fast and as responsive as possible, and in order to accomplish that with node's boot time, daemonizing just makes sense to help out with time and memory and block the main thread for the least amount of time possible while performing "format on save". Both README's for these two tools explain the philosophy behind their approaches.

To make null_ls as obvious and as isolated as possible, I did all configuration in a separate file that I require from my main lsp-config lua file.

To set up all null_ls stuff, I do this "somewhere":

require('plugin-config.lsp.integrations')

where I have a plugin-config/lsp/integrations.lua file where all the null_ls code lives.

The Code / config

In my file where I configure what plugins I want, I have this:

use {
  'jose-elias-alvarez/null-ls.nvim',
  requires = { "nvim-lua/plenary.nvim" }
}

I use packer for package management.

The official docs are really good, and I really just needed to have read them.

Then my plugin-config/lsp/integration.lua file has these contents, below -- this is the combination of a bunch of docs, example code, and other people's solution to problems. I'll explain in comments in the snippet in case things may not be clear.

---------------------------------------------------------
--
-- NULL LS is for hooking up non-LSP tools to the LSP UX
--
--
-- Be sure to :checkhealth to see if any underlying tools are missing
--
-- pnpm add --global @fsouza/prettierd cspell typescript
--
---------------------------------------------------------
local null_ls = require('null-ls')
local augroup = vim.api.nvim_create_augroup("LspFormatting", {})

-- TODO: figure out how to wire up ember-template-lint
local lsp_formatting = function(buffer)
  vim.lsp.buf.format({
    filter = function(client)
      -- By default, ignore any formatters provider by other LSPs 
      -- (such as those managed via lspconfig or mason)
      -- Also "eslint as a formatter" doesn't work :(
      return client.name == "null-ls"
    end,
    bufnr = buffer,
  })
end

-- Format on save
-- https://github.com/jose-elias-alvarez/null-ls.nvim/wiki/Avoiding-LSP-formatting-conflicts#neovim-08
local on_attach = function(client, buffer)
  -- the Buffer will be null in buffers like nvim-tree or new unsaved files
  if (not buffer) then
    return
  end

  if client.supports_method("textDocument/formatting") then
    vim.api.nvim_clear_autocmds({ group = augroup, buffer = buffer })
    vim.api.nvim_create_autocmd("BufWritePre", {
      group = augroup,
      buffer = buffer,
      callback = function()
        lsp_formatting(buffer)
      end,
    })
  end
end

null_ls.setup({
  sources = {
    -- Prettier, but faster (daemonized)
    null_ls.builtins.formatting.prettierd.with({
      filetypes = { 
        "css", "json", "jsonc","javascript", "typescript",
        "javascript.glimmer", "typescript.glimmer",
        "handlebars"
      }
    }),

    -- Code actions for staging hunks, blame, etc 
    null_ls.builtins.code_actions.gitsigns,
    null_ls.builtins.completion.luasnip,

    -- Spell check that has better tooling
    -- all stored locally
    -- https://github.com/streetsidesoftware/cspell
    null_ls.builtins.diagnostics.cspell.with({
      -- This file is symlinked from my dotfiles repo
      extra_args = { "--config", "~/.cspell.json" }
    }),
    null_ls.builtins.code_actions.cspell.with({
      -- This file is symlinked from my dotfiles repo
      extra_args = { "--config", "~/.cspell.json" }
    })
    -- null_ls.builtins.completion.spell,
  },
  on_attach = on_attach
})

ESLint

The native ESLint LSP is similar to prettierd, in that it has a long-running process from within which to invoke eslint from. I did this in a separate commit. You can see that I tried out eslint_d (and I've since removed it from the above code snippets in this post), but I ran in to an issue where the way eslint_d manages processes caused a lot of zombie processes when I'd eventually exit neovim. Since node tends to be memory heavy, having so many zombie processes just would not be acceptable.

The thing missing from the ESLint LSP is formatting with the LSP formatting integration.

The main things to have when using ESLint with "--fix on save", are:

local eslint = require('lspconfig').eslint

eslint.setup({
  on_attach = function(client, bufnr)
    vim.api.nvim_create_autocmd("BufWritePre", {
      buffer = bufnr,
      command = "EslintFixAll",
    })
  end,
})

And then I could remove the entries for diagnostics.eslint_d and formatting.eslint_d in my null_ls.setup.