null-ls.nvim custom code actions
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 namedcontext
) 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:
- We declare a variable with the directive content
- We get the first line of the file
- We check to see if they differ
- If they do, we return a table with a
title
that shows up as a prompt for the user and anaction
that is executed if the user selects our code action.
Here's what the title
looks like in :Telescope lsp_code_actions
:
After selecting our code action, we see our file has been updated with the directive:
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!