null-ls.nvim custom code actions

2021-12-31

NOTE: since this was published, I've released a plugin with this code action and more: ruby-code-actions.nvim.

Language servers are a great tool for empowering your editor, but your language server might not support every feature you want. So what can you do? Bring your own functionality!

What are my options for custom LSP features?

So far, I've looked into efm-langserver and null-ls.nvim.

efm-langserver is an editor-agnostic language server meant for helping you wire up external commands as diagnostics, code actions, etc. It looks promising but I've had trouble getting it working with neovim. YMMV.

Fortunately, for neovim we have null-ls.nvim. It allows you to "Use Neovim as a language server to inject LSP diagnostics, code actions, and more via Lua."

You might worry that writing custom actions in Lua means you're building something less portable than what you'd get with a script wired up to efm-langserver. Maybe. But you could always throw your Lua code in a shell script and call that from null-ls or from efm-langserver. The benefit of null-ls running Lua directly in neovim is that rather than communicating by shelling out processes, you're keeping everything in-process.

My null-ls setup

Here's my current packer.nvim config for null-ls:

use {
    'jose-elias-alvarez/null-ls.nvim',
    requires = {{'neovim/nvim-lspconfig'}, {'nvim-lua/plenary.nvim'}},
    config = function()
        local null_ls = require("null-ls")
        local sources = {
            null_ls.builtins.code_actions.gitsigns,
            null_ls.builtins.formatting.prettier,
            null_ls.builtins.formatting.mix,
            null_ls.builtins.formatting.rubocop,
            null_ls.builtins.diagnostics.rubocop,
            null_ls.builtins.diagnostics.shellcheck
        }
        require("custom_code_actions")
        null_ls.setup({sources = sources, debug = true})
    end
}

You'll note that null-ls provides some nice builtins for formatting, diagnostics, and code actions. Unfortunately there's no builtins for ruby code actions.

I keep my custom code actions in custom_code_actions.lua and register them with the require("custom_code_actions") line.

Let's write a simple code action to see how it works. We'll write a code action to insert the frozen_string_literal directive in our ruby file if it isn't already present.

How do we define a custom action?

Let's look at the skeleton of a code action:

local null_ls = require("null-ls")

local frozen_string_actions = {
    method = null_ls.methods.CODE_ACTION,
    filetypes = {"ruby"},
    generator = {
        fn = function(context)
          -- ...
        end
    }
}
null_ls.register(frozen_string_actions)

What's going on here?

  • We require null_ls to reference.
  • We define a table named frozen_string_actions.
  • It has a method key to specify that we're defining a code action.
  • It has a filetypes array that specifies which file types we want this to apply to.
  • It has a generator function that takes a param (here named context) describing the current context of the file in your editor (current selection, etc).
  • Finally, we register our code action with null-ls

The generator function

The generator function is invoked when the editor is considering which code actions to present to the user. The function should consider the context of the current file and return a table of relevant actions. It could return nothing if no actions are relevant.

Context params

Let's see what the context argument passed to our generator contains.

We create test.rb with the content

puts "Hello world"

If we change the body of our generator body to be print(vim.inspect(context)), open our test.rb in neovim, and request available code actions (I'm using :Telescope lsp_code_actions), our generator will be invoked, and we'll see the table passed in as context:

{
  bufname = "/private/tmp/out.rb",
  bufnr = 1,
  client_id = 1,
  col = 16,
  content = { 'puts "Hello world"', "" },
  ft = "ruby",
  lsp_method = "textDocument/codeAction",
  method = "NULL_LS_CODE_ACTION",
  range = {
    col = 17,
    end_col = 17,
    end_row = 1,
    row = 1
  },
  row = 1
}

For our needs, the important part is content. If the first line of the file, context.content[1], is something other than the frozen_string_literal directive, we should return an action to allow the user to insert the directive. If it does match the directive, our action isn't applicable so we return nothing.

The implementation

Here's an implementation of the generator function to conditionally insert the directive:

fn = function(context)
    frozen_string_literal_comment = "# frozen_string_literal: true"

    first_line = context.content[1]

    if first_line ~= frozen_string_literal_comment then
        return {
            {
                title = "🥶Add frozen string literal comment",
                action = function()
                    lines = {frozen_string_literal_comment, "", first_line}

                    vim.api
                        .nvim_buf_set_lines(context.bufnr, 0, 1, false, lines)
                end
            }
        }
    end
end

Walking through, we see that:

  1. We declare a variable with the directive content
  2. We get the first line of the file
  3. We check to see if they differ
  4. If they do, we return a table with a title that shows up as a prompt for the user and an action that is executed if the user selects our code action.

Here's what the title looks like in :Telescope lsp_code_actions:

our title in Telescope lsp_code_actions

After selecting our code action, we see our file has been updated with the directive:

the result of our code action

There's not much more to say here that you can't learn with :help (e.g. :help nvim_buf_set_lines).

Here's a gist with the entire implementation.

What's next?

Since we determine in our generator if the code action should apply, we might also want to consider the user's selection. If they've selected multiple lines of text, they're probably not intending to invoke a code action to add the frozen_string_literal directive. We could return nothing if we saw that the context.range.row differs from the context.range.end_row.

In a standalone ruby script, the frozen_string_literal directive can also appear on the second line. We could update our generator to insert the directive on the second line if there's a shebang line on line 1. We could return no action if the directive is on either the first or second line.

Have fun!

My goofy face

Hi, I'm Jeffrey Chupp.
I solve problems, often with code.