I've packaged up the code action from the last post into a neovim plugin: ruby-code-actions.nvim. Since publishing the plugin, I've added new code actions for correcting the current line, selected lines, or entire file with RuboCop.
I wrote it using plenary for unit testing and found the experience lovely. It has good mocking support for vim.api and your custom code. I couldn't out the correct way to mock things like vim.cmd and such but I worked around this by using vim.api equivalents or wrapping calls and mocking the wrappers. I can't say enough nice things about plenary or TJ DeVries' work on telescope and neovim itself.
In the end, I have a spec file I can run via make and the development process feels more like the comfortable TDD I'm used to.
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.
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:
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 an action 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).
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.
Applescript is useful but interpreted scripts are fairly slow to start.
Consider a script that only has the number 1 in it.
$ echo "1" > example.scpt
When run with osascript example.scpt, this will output 1.
A timed run of the script shows it takes roughly 430 milliseconds on my machine.
$ time osascript example.scpt
1
real 0m0.429s
user 0m0.053s
sys 0m0.053s
We can't have a much simpler script than this, so it looks like the price of using interpreted Applescript is a minimum of ~430ms. If you have a script that runs frequently, this might be too slow.
You might not be aware that you can compile AppleScript. Not surprisingly, this compilation improves the startup time.
$ osacompile -x -o compiled-example example.scpt
$ time osascript compiled-example
1
real 0m0.252s
user 0m0.034s
sys 0m0.036s
We're down from over 400ms to 250ms. That's still not ideal to run in a tight loop, but the savings might be compelling depending on your usage.