Seb's blog

Extending rust-tools executors

I've just migrated from IntelliJ CLion to Neovim since I thought it wasn't worth spending loads of time to set up Neovim to work with Rust but I was partially wrong.

I've used Vim and Neovim for a long time already mostly to write Ruby code, which doesn't require the same setup you'd generally need for a compiled implementation of a programming language.

As I used CLion for over half a year I was able to try new things - I never used an IDE for development before - and no complains here, CLion has a lot of good things to offer. But I was just missing the flexibility and ease I can have with Neovim.

One of the things I have in my Neovim configuration when working with Ruby and Rails projects is the ability to run the tests under the cursor with a couple of keystrokes. This takes a few lines to create a function and map it in normal mode;

function! ExecuteBinRspecNearest()
  " Get the current line where the cursor is.
  let l:last_file_tested = resolve(expand('%:p')) . ":" . line(".")
  " Prepare the command to run.
  let l:command = "bundle exec rspec -fp " . last_file_tested
  " Define in which TMUX pane this command will be executed.
  let l:pane = "right:0"

  " Send the command to the given TMUX pane and execute it. Silently.
  silent! execute "!tmux send-keys -t " . pane . " '" . command . "' ENTER"
endfunction

" Create the keybinding.
nnoremap <Leader>etn :call ExecuteBinRspecNearest() <CR>

And that's all what it takes.

Although I know I cannot expect the same when working with Rust, as the tests can not be run from the line their - a test function as an RSpec it - scope is written but have to be referenced by their name.

So I found myself trying to get the same feature in Neovim and I ended up installing simrat39/rust-tools.nvim, which luckily enough, implements a lot of the features we'd normally need for working with Rust. But as there's always a nitpick, it only has 2 (documented) ways to run your tests and see their output. Which is;

and none of those provided the way to keep a different stable (non floating, non ephemeral) terminal where to execute the tests, see their outputs, etc.

So I had to go ahead and check what could be done to execute the tests on a specific TMUX pane. At the end rust-tools pass just a command like any other which is executed and shown to the user.

So, checking the docs there's a way to tell which executor you want to use (any of the 3 options mentioned previously) that seems to expect a table with a single function execute_command(command, args, cwd) to be defined there.

Knowing this is the only thing you need makes extending the plugin for this purpose easier.

We can create a local table where we'll define our execute_command function and pass it as the executor in the config;

local executor = {}

Then we can create our function within the executor;

function executor.execute_command(command, args, _)
end

Notice we're omitting the 3rd argument as we don't need it for this. But it's available for you if you ever do so.

After that we need to know where our command will end up. For this I used the same plugin config and saved the pane there;

rust_tools.setup({
  tools = {
    executor_pane = "right:0"
  },
})

Which is usually the first pane in a TMUX session I have always open in my right screen.

So, to use this we can get it straight from the plugin options;

function executor.execute_command(command, args, _)
  local pane = rust_tools.config.options.tools.executor_pane
end

Then for the record, we can check whether the executor_pane is in the configuration, if it's not then we can notify the user (ourselves) with a helpful error message;

function executor.execute_command(command, args, _)
  local pane = rust_tools.config.options.tools.executor_pane

  if pane ~= nil then
  else
    vim.notify(
      "there's no TMUX pane configured for this executor. "
        .. "set one in the plugins options and try again.",
      vim.log.levels.error
    )
  end
end

And in case we have an executor_pane, we can create the whole command and send it to the given TMUX pane;

function executor.execute_command(command, args, _)
  local pane = rust_tools.config.options.tools.executor_pane

  if pane ~= nil then
    local tmux_command = command .. " " .. table.concat(args, " ")
    local system_command = "tmux send-keys -t "
      .. pane
      .. " '"
      .. tmux_command
      .. "' enter"

    vim.fn.system(system_command)
  else
    vim.notify(
      "there's no TMUX pane configured for this executor. "
        .. "set one in the plugins options and try again.",
      vim.log.levels.error
    )
  end
end

We do it by concatenating every value in the command table and then creating the whole command that will be executed in TMUX with tmux send-keys.

Now we need to tell rust-tools to use our executor table through the configuration;

rust_tools.setup({
  tools = {
    executor = executor,
    executor_pane = "right:0",
  },
})

So every time I need to run any Rust test, I can just jump to the function definition, open the LSP hover actions (provided by rust-analyzer), choose it and the test will be executed in the given TMUX pane.

#lua #neovim #rust #rust-tools #tmux