Maciej Janicki's website

2020-09-26 programming vim tmux

Executing code chunks from Vim

Recently “notebook” applications like Jupyter and RStudio have become a popular way of teaching and practicing programming. Their advantage is making programming interactive: you write several lines of code, then execute it and look what happens. You can tinker with your complex data structures and try unfamiliar functions out without the need to rerun the whole script each time. However, this also comes with the massive disadvantage: you are locked down to a GUI application and cannot edit the code with a decent text editor. In this tutorial, I show how to achieve the same effect using Vim, the standard REPL console, and tmux to connect them.

Note: tmux or “terminal multiplexer” is a program that allows you to manage multiple terminal emulators inside one text terminal or window. In the tutorial below, I assume that you are familiar with the basic concepts of tmux, like sessions, windows and panes. If not, I strongly recommend to check out this amazing piece of software.

Before we get started, here’s a short demo of what we’re aiming at:

Demo of executing chunks of R code.

You can see that the first time I hit Ctrl+E inside Vim, a new tmux window pops up, showing a session named repl-R and running the R interpreter. Then I can select chunks of code and press Ctrl+E again, which sends the selected code to the interpreter. Inside a tiling window manager (mine is dwm), it looks almost like an RStudio session, except that I’m using my favourite terminal-based tools. Furthermore, it be done exactly the same way for different text editors (e.g. Emacs or vis) and different interpreters (Python, Haskell, what have you). Let’s get down to implementing it.

Step 1. Sending keys to a tmux pane from outside

In the first step, we’re going to learn how to control an application running in a tmux pane by running shell commands from outside that pane.

Execute the following steps:

  1. Start a new tmux session called “test” by running
    tmux new -s test
    

    in a terminal emulator. This will create the session and a new window with ID 0 inside it.

  2. Open a completely new terminal window (even without running tmux inside, doesn’t matter) and execute the following command:
    tmux send-keys -t test:0 ls Enter
    
  3. Go back to the window running the test session. You should see that the ls command has been run in window 0!

As the name suggests, the command send-keys sends keypress events to the tmux pane indicated by the -t parameter. This way we can control any application that’s running there from outside.

Now let’s modify those steps slightly to get closer to the goal. In step one, instead run:

tmux new -s test R

This will open the R interpreter in the newly created window. In step two, run:

tmux send-keys -t test:0 'data(iris)' Enter 'hist(Sepal.Width)' Enter

You should see a nice histogram pop up!

Step 2. Sending the Vim selection

We now know how to send keys to an application running inside a tmux pane with a shell command. Now we will learn how to run this command from Vim and pass the currently selected text as parameter.

Vim allows you to run an external command and pass the currently selected lines to its standard input using the :write command. You simply pass the command preceded with a bang sign (!) instead of a filename. Try it by selecting some text in Vim (in visual mode) and running:

:write !tr a b

A new dialog window should pop up, in which you will see the same text with all a’s replaced with b’s. This is the output of the command.

Our goal is to call the tmux send-keys ... command on the selected block of code (say, R code for this example). However, we need to preprocess the code a little bit to ensure that:

  • each line is followed by Enter,
  • the whitespaces, quotes and other special characters are passed correctly, instead of breaking the shell command.

Below is the function that does the whole work. You can first add it to your .vimrc and test it a little bit. Then we will discuss it in detail:

function! SendToREPL()
    silent! execute '''<,''>write
      \ !sed "s/.*/\0\nEnter/"
      \ | tr "\n" "\0"
      \ | xargs -0 tmux send-keys -t ' . b:repl_session . ':0.0'
endfunction

In order to test it, you need to set the variable b:repl_session to the name of the tmux session, in which our R interpreter is running:

:let b:repl_session="test"

Then you can call the function by selecting some R code in visual mode and running:

:call SendToREPL()

Note that when you press : in visual mode, the prompt that appears looks like this: :'<,'> - i.e. it contains the selected range. You need to remove those extra characters (up to the colon) to achieve the proper behaviour. We will automatize this in Step 3.

Let’s have a closer look at SendToREPL(). The first line: silent! execute '''<''>write just executes Vim’s write command on the currently selected range. silent! means that we don’t want the dialog window with the output to pop up (because we’re not expecting any output) and the whole command is enclosed in single quotes ('), with quotes inside the command being escaped by doubling them.

The three subsequent lines contain the shell command that is passed to write. It’s actually a pipeline of three commands:

  • sed "s/.*/\0\nEnter/" adds after each line a line consisting of Enter,
  • tr "\n" "\0" changes all line breaks to the NUL character - this is important for the next command,
  • xargs -0 tmux send-keys -t ' . b:repl_session .':0.0' calls the tmux send-keys command while passing the lines from its stdin as arguments. The -0 parameter means that the input lines are separated by NUL characters instead of newlines - this way they are passed literally, no matter what whitespace and special characters they contain. As the target tmux pane, we set the pane 0 of window 0 of the session, the name of which is written in the variable b:repl_session.

Step 3. Ensuring that the interpreter is running

The missing part is now the first Ctrl+E press, which starts the tmux session running the interpreter. In order to simplify this process, we will set some variables based on the file type:

autocmd FileType r,rmd let b:repl_cmd = "R"
autocmd FileType python let b:repl_cmd = "python3"
autocmd FileType r,rmd,python let b:repl_session = "repl-" . b:repl_cmd

The b: prefix means that the variables are local to the buffer. We set two variables: b:repl_cmd is the command to run the appropriate interpreter, while b:repl_session is the name of the tmux session where it’s supposed to be running.

Now we define the following function which starts the interpreter if it’s not already running:

function! EnsureREPL()
    if exists("b:repl_cmd")
        call system("tmux has-session -t " . b:repl_session . 
                    \ " || ( st -e tmux new-session -s " .
                    \ b:repl_session . " " . b:repl_cmd . " ) &")
    endif
endfunction

The tmux has-session command checks whether the session is already running. If not, we start it in a new terminal window - remember to replace st in line 4 with your terminal emulator of choice.

Finally, we set the Ctrl+E keybinding for both normal and visual mode:

autocmd FileType r,rmd,python
  \ nnoremap <C-e> :silent! call EnsureREPL()<CR>
autocmd FileType r,rmd,python
  \ vnoremap <C-e> :<C-u>silent! call SendToREPL()<CR>

Final remarks

As I’ve noted at the beginning, the workflow is not Vim-specific and can be implemented in any text editor that allows selecting a chunk of text and piping it to an external shell command. The essential part here is tmux and this is one example for how powerful this application is when it comes to managing text terminals.

Some might say that there are editors that allow you to run a terminal inside an editor buffer, like neovim. I find this solution somehow inelegant - you typically run a text editor inside a terminal, so you shouldn’t be running another terminal inside the editor. Here we use the editor for editing text and delegate everything else to external applications.

Using this solution for R, I completely eliminated RStudio from my workflow. I don’t miss its constant crashes at all!