A Case Study in Vim Script 101: Making a Test Runner

"Hello there, what's this?" you ask. It's a case study in how one might use Vim script, a programming language built in to Vim. We'll walk through the creation of a Vim script from stem to stern, ending up with our test runner that can run a variety of tests with a few simple keystrokes. […]

"Hello there, what's this?" you ask. It's a case study in how one might use
Vim script, a programming language built in to Vim. We'll walk through the
creation of a Vim script from stem to stern, ending up with our test runner
that can run a variety of tests with a few simple keystrokes.

The discussion below assumes your Vim version is at least 8.1. It also
assumes that you are comfortable with the basic vernacular of Vim, programming,
and running tests.

If you ever feel like jumping to the punchline, the entire resulting Vim script
is here.

What Are Our Goals Here, and Why?

Writing software can require running tests innumerable times, particularly if
we adhere to a strict form of test driven development ("TDD"). The faster we
can run the tests, and the more visible the feedback, the better. Rooted in
this premise, one of our goals here is to imbue Vim with the ability to run
tests quickly and simply, right next to our code in a Vim window. We want to
trigger the tests with only a few keystrokes, and we want to close the test
window just as quickly once we are satisfied so we can move on. Something like
this:

posts/2021-01-05-vim-script-case-study/goal.gif

We also want our keystrokes to have some targeted focus. They should allow us
to run the tests in our entire suite, run only the tests in our current file,
or run only a single test. If these keystrokes work the same when
using different testing frameworks or libraries, well, then we're really
in the money.

"But wait," you might say. "Aren't there Vim plugins for this?" Indeed, there
are. Great ones, in fact, like
Test.vim. It's reasonable to assume
that, on the surface, another one our goals here is to have something other than a
plugin to get quick and convenient feedback from tests while working with Vim.
This assumption doesn't quite hit the mark, unfortunately. The script we make
here is imperfect. And a significant part of the discussion here focuses on
exposing its flaws. So why go into all this? Because our ultimate goal is
to offer a journey where we begin to work with Vim script and stir our
imaginations to consider how Vim script might help in other ways. Vim script is
incredibly powerful, makes Vim highly configurable, and enables us to tell Vim
how to execute complex behavior. If something here inspires a way to make your
life easier, now or in the future, it will be well worth the journey.

Our Vim script ultimately maps simple keystrokes to testing commands for the
following frameworks:

  • Rspec (for Ruby)
  • ExUnit (for Elixir)
  • Jest (for JavaScript/TypeScript/React)

If you use different testing frameworks (or languages), fear not. With a few
tweaks, you should be able to modify the script to suit your needs. In fact,
finding something here in Vim script that you can modify or use differently is
more in line with the ultimate goal of this case study than simply
copy-and-pasting wholesale.

What Exactly is Vim Script (a.k.a, Vimscript)?

Vim script is a programming language built in to Vim. It basically allows us to
do cool stuff with Vim. Put more formally, Vim script allows us to define and
trigger desired Vim behavior with code consisting of Ex-commands, functions,
variables, certain data structures, etc. If you are familiar with setting
options for Vim in Command-line mode (after typing : in Normal mode), or
with tinkering with your .vimrc file, then you have been writing simple Vim
scripts already. You can place any Vim script in your ~/.vimrc file (or
another file that ~/.vimrc loads), and the script will be available the next
time you start Vim or re-load your ~/.vimrc file.

More information on Vim scripting, as well as anything else discussed here, can
be found in the Vim help files, accessed through the :help command. [FN 1] In particular,
Vim has a built-in manual on scripting that can be accessed with the command
:help usr_41.txt. The very first paragraph doesn't hide the fact that "this
is a long chapter" in the Vim help files. Nevertheless, there is a ton of
helpful background and detail here that is well worth reading if you are
interested in more about Vim script.

A Brief Refresher on Vim Mapping

Let's take a step back before we write any scripts to run tests. How should we
trigger the execution of the script? A simple mapping of keystrokes. But how
do we create this mapping?

Fortunately, there's no magic to mapping in Vim. We can pretty much create any
map we'd like. For example, say we're in Normal mode and we want Vim to display
"Hello!" to us under the status bar. We can type : to enter Command-line
mode. Then we enter the echo command with our message:

:echo "Hello!" 

Afterward, of course, we hit the Enter key. "Hello" flashes before us at the
bottom of our Vim screen.

posts/2021-01-05-vim-script-case-study/echo-hello.gif

But we're friendly—we often like to see "Hello" at a whim without having to
type all that out. Let's make our lives easier with a mapping. From Normal
mode, we'll type : to enter Command-line mode. Then we add some special sauce:

:nnoremap ,h :echo "Hello!"<CR> 

Let's break down the meaning of each part of this devilry:

  • nnoremap — Set up a key mapping that will work only when we are in Normal
    mode. We could also do this with nmap. But the extra noremap basically
    means that we cannot trigger this map with another map. [FN 2]
  • ,h — When we hit these keystrokes, Vim should do what follows as if we
    typed it in Normal mode.
  • :echo "Hello!" — This is what we want ,h to do. That is, from Normal
    mode, Vim should enter Command-line mode by hitting : and then echo "Hello!" to
    us.
  • <CR> — This is a stand-in for hitting the Enter key, allowing us to run the
    command without having to hit it.

Now, we can hit ,h repeatedly in Normal mode, and we will see "Hello!" as
often as we wish.

This is nice, of course, but there is a key take-away: We can map simple
keystrokes like this to any Vim command. And there's more. As we'll find
out later, if we want more complex behavior from a mapping, we can define that
behavior in a Vim script function and have our map call the function.
That's just what we're going to do below to create a test runner. The ability
to create maps in this way is amazingly powerful.

Where Should Mappings (and Vim Scripts) Go?

Let's assume we love our "Hello!" mapping. We love it so much that we want it
to be available every time we open Vim. But can we avoid typing out
the map in Command-line mode every time we start Vim? Of course. When Vim
loads up, it reads our ~/.vimrc file as if we had entered everything there in
Command-line mode. We can add our map to our ~/.vimrc file, without the
preceding colon (:), and our map keystrokes will be available every time we
open Vim.

nnoremap ,h :echo "Hello!"<CR> 

From now on, we'll assume all of our script is going in our ~/.vimrc file.
If, instead, we are entering anything in Command-line mode, we'll prefix the
command with a colon (:) (and we will presumably hit the Enter key after).
Anything without a prefixed colon will be for our ~/.vimrc file.

One other thing to note: to make creating maps in our ~/.vimrc file easier,
we'll want to consider setting and using a <Leader> key. A <Leader> key
is basically a variable, or abstraction, of one keystroke. For
example, we can set our <Leader> key to a comma:

let mapleader = ","

And after that, we can map our "Hello!" command using the <Leader> key:

nnoremap <Leader>h :echo "Hello!"<CR> 

Many people set their <Leader> key to a comma, which is why our "Hello!"
command was mapped to ,h. Whatever <Leader> key one chooses, using this
convention allows us to change the <Leader> key at a whim, without having to
modify all of our other maps that depend on it. From here on out, we'll be
using the <Leader> convention when writing our maps. The Vim help files have
more information. [FN 3]

Mapping a Simple Test Runner for Rspec Tests

Armed with this knowledge on Normal mode mapping, we can now create simple maps
to run our tests at a whim.

Suppose we are working on a Ruby project with Rspec as our testing framework.
At the command line, after cd'ing into our project's root directory, we could
type rspec to run all of our tests. Vim allows us to run such commands,
without leaving Vim, by entering Command-line mode and prepending our command
with a !. So, assuming we have opened Vim from our project's
root directory, we could tell Vim to run our Rspec tests like so:

:!rspec

Running this in Command-line mode causes Vim to disappear. Our terminal
command-line appears, and our tests are run.

posts/2021-01-05-vim-script-case-study/traditional-rspec.gif

Let's map this command in our ~/.vimrc file. Our keystrokes will be
<Leader>ta, as an abbreviation for "Test All".

nnoremap <Leader>ta :!rspec<CR>

What if we want to test only the current file, rather than run every test in
the repo? Quite helpfully, Vim allows us to use symbols to specify a range in
Command-line mode. For example:

  • % stands for the current file, and
  • . (i.e., a plain old period) stands for the line where the cursor is located. [FN 4]

Armed with this knowledge, let's create a <Leader>tf, or "Test File", map to
run only the tests in our current file. We'll use the % symbol noted above.

nnoremap <Leader>tf :!rspec %<CR>

This is a more targeted keymap than "Test All", but sometimes, we want to be
even more targeted. For example, if we find ourselves facing one particularly
hairy test, we might prefer to run only that one test over and over.
Fortunately, we can create a map to run a single test, but it gets a little
more complicated. This blog
post
has a
great, in-depth explanation of how we do this. But long story short, Rspec is
smart enough to know that we only want to run a single test if we enter something
matching this pattern at the command-line:

rspec <path-and-file-name>:<any-line-number-of-a-specific-test> 

For example, say we want to run a single test in a test_ai.rb file. This
test takes up lines 100-110. The following command would run the test, even
though we specify a line somewhere in the middle of the test.

rspec /spec/test_ai.rb:103 

The awesomeness of this convention truly comes to light when we realize that Vim
has a built-in method to tell us what line our cursor is on: line(".") With
this method, we can create a map that runs only the test where our cursor is.

This is what our new map will look like when we put it all together. We'll
assign the map to the <Leader>tt keystrokes, for "Test This". And we'll lean
on a few other Vim methods to pull it off: execute, which allows us to
concatenate a string from variables or other functions and execute that string
as a command [FN 5]; and ..
(double periods), which is a string concatenation operator. [FN 6]

nnoremap <Leader>tt :execute "!rspec %:" .. line(".")<CR>

Now, if our cursor is on line 103 of our test_ai.rb file, this map will
effectively run the following at the command line to run only the test
that includes line 103:

rspec /spec/test_ai.rb:103

For now, our three main maps are complete. After putting them in our ~/.vimrc
file, it takes just a few simple keystrokes to run just one test, one test file,
or our entire test suite with Rspec.

Getting Tests to Run in a Split Window Terminal

The above commands are great, but I have a problem with them. Any command
prefixed with ! hides Vim completely. If any of my tests fail, I cannot read
the test feedback and my code side by side. This makes it difficult to correct
the failure. Let's see if we can remedy the situation.

Neovim has long had the ability to open a terminal window as a new Vim window,
side-by-side with our code. This feature did not make its way to plain old Vim
until the arrival of Vim 8.1 in
2018. Now that it's here, let's use it.

The following will open a terminal, in a horizontally split window, in Command-line mode:

:terminal

Conversely, this will open a vertically split window (which I happen to like
better, so we'll continue to use it below):

:vertical terminal

posts/2021-01-05-vim-script-case-study/terminal-plain.gif

As long as a process is not running in the terminal, we can close the terminal
window just as we would any other Vim window. For example, we could enter :q,
or :close, or :bd. We can also type exit at the command prompt. If
a process is still running, we could enter :q!, :close!, etc. to forcibly
close the terminal window.

Now, here's where things really get interesting about a Vim terminal. We can
pass additional arguments to our command to open the terminal. The
terminal will then automatically run these arguments as terminal commands. For
example, this opens up a terminal window and automatically prints our current
working directory.

:vertical terminal pwd

posts/2021-01-05-vim-script-case-study/terminal-pwd.gif

Going back to the Rspec maps we wrote above, if you're like me and find it
annoying that Vim disappears when Rspec runs tests, we can now remedy the
situation. We'll rewrite our maps to take advantage of the vertical
terminal
command, and Rspec will run our tests in a new, vertically split, Vim
terminal window.

" test all "
nnoremap <Leader>ta :vertical terminal rspec<CR>

" test file "
nnoremap <Leader>tf :vertical terminal rspec %<CR>

" test this "
nnoremap <Leader>tt :execute "vertical terminal rspec %:" . line(".")<CR>

posts/2021-01-05-vim-script-case-study/first-vertical-test.gif

Fantastic.

Closing Our Terminal and Writing Our First Vim Function

Our maps now run tests in a vertically split window. The feedback from the
tests remains on screen, which is helpful when we want to fix any failing tests.
But what if all our tests have passed and we want to close the terminal window
quickly?

We could create a new map that closed the terminal window with q
or q!. This seems risky, however. What if we hit the keystrokes during
normal text editing and accidentally close a window, with disastrous results?
Can we put conditions on the new map to protect ourselves? We can, and to do
so, we'll have to create a function in Vim script.

Functions in Vim script resemble functions in other languages. And helpfully,
any Vim command can go in our function. For example, going back to our
obsession with having Vim say "Hello!" to us, let's create a function to handle
this behavior.

function SayHello()
  echo "Hello!"
endfunction

Now we can map the function to <Leader>h, executing it with a call command.

nnoremap <Leader>h :call SayHello()<CR>

Our map to say "Hello!" works just as before, only now we are triggering
behavior though a function call.

We can of course use variables in our functions. Variables are declared with
let. Interestingly, Vim allows us to attach a prefix to a variable's name to
scope the variable. [FN 7] For
example, an l: prefix means a variable is local to a function, while g:
means a variable is global and available throughout our Vim session, even in
Command-line mode. An a: prefix is necessary to access an argument declared in
the function's signature.

As a demonstration of these prefixes, let's write a simple function that uses
some variables and concatenates strings with the .. operator.

let g:global_boss = "Bowser"

function SayHelloToEveryoneIncluding(name)
  let l:local_boss = "Koopa"
  echo "Hi " .. g:global_boss .. ", " .. l:local_boss .. ", and " .. a:name .. "!"
endfunction

" In Command-line mode enter this "
:call SayHelloToEveryoneIncluding("Mario")

" Output: "
" Hi Bowser, Koopa, and Mario! "

You might have questioned why some names in the function are camelCased,
some are snake_cased, and some are simply nocased. I find it confusing
as well, but it's partially the result of how Vim script functions are declared
and partially the result of attempting to follow Google's Vim scripting style
guide
.

One last thing to note before we move on: if you have ever used the set
command, either in Command-line mode or in your ~/.vimrc file, you have been
manipulating a Vim option. This, for example, sets our tabstop option to 2
spaces.

:set tabstop=2

When writing a Vim script function, we can access the value of an option by
prefixing the option name with &. [FN 8] Given the setting above, this function will echo
"2".

function WhatIsTabStop()
  echo &tabstop
endfunction

And we can set a new value for an option using let and
the & prefix. Omitting let allows us to reference the option's current
value. This function adds another 2 spaces to the current tabstop.

function AddTwoToTabStop()
  " Set tabstop value based on current value, plus 2 "
  let &tabstop = &tabstop + 2
endfunction

If we want to add conditions and control flow logic, our Vim script starts to
look pretty much how we would expect it to look, given the conventions in other
modern programming languages. It uses if, elseif, and else.

function AdjustTabStop()
  if &tabstop < 10
    let &tabstop = &tabstop + 2
  else
    let &tabstop = &tabstop - 2
  endif
endfunction

All this background on Vim functions is a mouthful. But with this basic
knowledge on writing functions, hopefully we can begin to come up with a way to
close terminal windows and not other windows.

As luck would have it, while considering this dilemma, I came across several
helpful Vim-isms that lend themselves to this cause:

  1. A buffer in Vim is an in-memory text of a file for editing. A window is
    how a user views a buffer. [FN 9]
  2. When a buffer is opened, Vim automatically assigns it a "buffer number."
    Each buffer may or may not have a "buffer type". When a buffer opens for a
    terminal, Vim automatically assigns it a buffer type equal to "terminal". We
    can read the value of the buffer type with the &buftype option.
  3. Vim provides some awesome helper functions. Whenever facing a scripting
    challenge, it's worth going to Vim's help file on functions and scrolling for
    solutions or assistance. [FN 10]
    There, I found the helper function bufnr, which allows us to
    programmatically obtain the number of the current buffer window. I also stumbled
    across term_getstatus, which takes a buffer number as an argument and tells us
    if the terminal has "finished" or is "running" a process.

With all this in mind, we can sew together a function that only quits a window
if the window has a terminal that has finished running a process.

function CloseTest() 
  " Use buftype to check that we're in a terminal "
  if &buftype == "terminal"

    " Use the buffer number of the current window to make sure that "
    " any terminal process, like running our tests, has stopped "
    let l:buffer_number = bufnr("%")
    let l:terminal_status = term_getstatus(buffer_number)

    " If the terminal has finished, with all processes, close the "
    " window using the quit command "
    if l:terminal_status == "finished"
      q
    endif
  endif
endfunction

And now we can map this function to <Leader>ct, for "Close Test".

nnoremap <Leader>cl :call CloseTest()<CR>

With that, we have four awesome maps to run tests with Rspec quickly and close
the test window quickly when we are done.

  • <Leader>ta — "Test All" => Run all tests for repo
  • <Leader>tf — "Test File" => Run all tests within our current file
  • <Leader>tt — "Test This" => Run a single test closest to the cursor
  • <Leader>ct — "Close Test" => Close the window if it's a terminal window
    that has finished running all processes

Abstracting Away the Test Command to Work with Other Testing Libraries

Let's assume we are pretty happy with our test runner and maps. There's just
one little problem. Ruby is not the only programming language we use, and Rspec
is not the only testing framework we use. Say, for example, we do a lot of
programming in Elixir, using ExUnit for testing. Is there any way
we can use the same maps to run tests with Rspec and ExUnit, without
having to tinker in our Vim script all the time?

One thing that works to our advantage is that test commands in different
frameworks "tend" to follow the same conventions as Rspec. Compare the
following commands to run Rspec with the mix test command, which runs ExUnit
tests for Elixir:

rspec => Run all Ruby tests with Rspec
mix test => Run all Elixir tests with ExUnit

rspec <path-and-file-name> => Run all tests in this file with Rspec 
mix test <path-and-file-name> => Run all tests in this file with ExUnit 

rspec <path-and-file-name>:<line-number> => Run a single test with Rspec 
mix test <path-and-file-name>:<line-number> => Run a single test with ExUnit 

All that really changes is the base command. So let's see if we can imagine a
function that gives us the correct test command, depending on what language
we're using.

Vim has built in helpers for detecting a file's language, but we're going to
bypass these advanced techniques and use something simpler for now—the file's
extension. [FN 11] To access
this, we'll use an expand method, which will give us the relative path and
name of the file in the current window if we pass % as an argument. [FN 12]

expand("%")

This method will even give us the file's extension if we append a modifier,
:e, to the argument. Let's use this to capture the file's extension in a local
variable.

let l:extension = expand("%:e")

Now we can create a function that returns the rspec or mix test command,
depending on whether we are working on a Ruby file (with an .rb extension) or
an Elixir test file (with an .exs extension). Note that functions in Vim
script always return a value, but unless we specify that value with the return
statement, the return value will 0. [FN 13]

function GetTestCommandByExt()
  let l:extension = expand("%:e")
  if l:extension == "rb"
    return "rspec"
  elseif l:extension == "exs"
    return "mix test"
  endif
endfunction

Now let's update our maps. Rather than call !rspec directly, they will call
new functions: TestAll(), TestFile(), and TestThis(). Each of these, in
turn, will call GetTestCommandByExt() to get the relevant test command and
store that command in a local variable. The new functions will wrap up by using
expand to concatenate everything into a command to open up the vertical
terminal and run the test we want.

" Re-map our keystrokes to new functions, rather than call `!rspec` directly "
nnoremap <Leader>ta :call TestAll()<CR>
nnoremap <Leader>tf :call TestFile()<CR>
nnoremap <Leader>tt :call TestThis()<CR>

" Introduce the new functions, which use `GetTestCommandByExt()` "
function TestAll()
  let l:test_command = GetTestCommandByExt()
  execute "vertical terminal" l:test_command
endfunction

function TestFile()
  let l:test_command = GetTestCommandByExt()
  execute "vert term" l:test_command "%"
endfunction

function TestThis()
  let l:test_command = GetTestCommand()
  execute "vert term" l:test_command "%:" . line(".")
endfunction

Wonderful! But now, of course, there is a big problem. What if we are working
in a file that GetTestCommandByExt() does not recognize? How do we handle
that?

Vim script, like many other languages, permits a throw command to
purposefully throw errors. It also permits handling errors with try / catch
blocks. Let's take advantage of these features. Our working plan is that
GetTestCommandByExt() should throw an error if it does not recognize a file's
extension. Then we'll echo a simple warning in red at the bottom of our Vim
screen to let the user know what happened.

Implementing this plan turned out to be surprisingly complicated for me. I'll
try to spare you the incredibly boring details and trial-and-error sagas. But
long story short(ish), if we use an intuitive error-displaying method to echo
the error, such as Vim's built-in method echoerr [FN 14], our script will end up displaying multiple,
off-topic error messages.

posts/2021-01-05-vim-script-case-study/multiple-errors.png

We only want one message. To solve for this, we'll
have to create a custom method. This method will display a warning by changing
the color of an echo'd message to red, echoing the message, and then resetting
the color. [FN 15]. It looks like this.

function FireWarning(warning)
  " Change echo color to red "
  echohl WarningMsg
  " Display the message "
  echo a:warning
  " Reset the echo coloring "
  echohl None
endfunction

Now we can throw an error in GetTestCommandByExt() if it does not recognize
the file extension…

function GetTestCommandByExt()
  let l:extension = expand("%:e")
  if l:extension == "rb"
    return "rspec"
  elseif l:extension == "exs"
    return "mix test"

  " Adding this ..."
  else 
    throw 'Test file extension not recognized.'
  endif
endfunction

…and we'll update our test functions to handle the error with try / catch
blocks.

function TestAll()
  try
    let l:test_command = GetTestCommand()
    execute "vert term" l:test_command
  catch
    call FireWarning(v:exception)
  endtry
endfunction

function TestFile()
  try
    let l:test_command = GetTestCommand()
    execute "vert term" l:test_command "%"
  catch
    call FireWarning(v:exception)
  endtry
endfunction

function TestThis()
  try
    let l:test_command = GetTestCommand()
    execute "vert term" l:test_command "%:" . line(".")
  catch
    call FireWarning(v:exception)
  endtry
endfunction

Note that GetTestCommandByExt() throws an error with a custom message, Test
file extension not recognized.
When a catch block handles the error, the
catch block accesses the custom message by using a special variable,
v:exception. In our case, each catch block simply passes this special
variable as an argument to FireWarning(). FireWarning() can then display
the custom message as a warning.

posts/2021-01-05-vim-script-case-study/working-error-message.gif

Taking a Step Back

Where has all this Vim scripting left us? Using maps, we have configured Vim
to respond to three simple keystrokes: <Leader>ta, <Leader>tf, and
<Leader>tt. These keystrokes run multiple or single tests at our whim,
requiring barely a thought from us. This certainly comes in handy if we adhere
to test-driven-development's rhythm of alternating rapidly between writing
tests and writing code. Thanks to running the tests in a vertically-oriented
Vim window terminal, the feedback from the tests persists on screen when we
need to fix something in our code. We also have another keystroke, <Leader>ct,
which closes the tests as easily as we opened them. But our configuration has
added safeguards to ensure this keystroke does not accidentally close a window
with code we are editing. In addition, our keystrokes, quite helpfully, don't
care if we've run tests in Ruby or Elixir. Our Vim script has included some
abstraction, and the mapped keystrokes work for both languages. And, if our
Vim script does not know what test command to use, we get a helpful warning.

This all sounds great. Not bad for an honest day's work. And, of course, we
can't leave well enough alone.

Adding a Test Command That Doesn't Play Nicely, as well as a Global Override

We discussed above how Rspec and ExUnit commands follow an identical pattern to
run an entire suite, a single test file, or a single test. This allowed us to
abstract away our test command in our testing functions. We then delegated the
determination of the proper test command to another function,
GetTestCommandByExt(). GetTestCommandByExt() looks to the extension of our
current file to identify whether we were working in Ruby or Elixir. It then
supplies the proper Rspec or ExUnit test command accordingly.

Let's say that we also do a lot of work in JavaScript, specifically working with
React and TypeScript. Our assertion library is Jest, and typically we run our
entire test suite by running the command npm test. This runs our tests in
interactive, "watch" mode, meaning the tests do not close automatically once
they are run. The test runner remains open to re-run tests until we explicitly
close the test runner.

posts/2021-01-05-vim-script-case-study/npm-interactive-mode.gif

For better or worse, our CloseTest() function, mapped to <Leader>cl, only
works to close a terminal window if the process in the terminal is finished. So
our first order of business is figuring out how to run Jest so the test
runner exits after running its course once. Thankfully, this is fairly
straight-forward. We can pass the flags
-- --watchAll=false to npm test.

Let's add a new branch of logic to GetTestCommandByExt() to account for this.
Our function will return npm test -- --watchAll=false if it determines that we
are working in a JavaScript file, TypeScript file, or Typescript file with JSX.
We'll lean on Vim script's logical "or" operator, ||, to condense this down to
two new lines. (Vim script's logical "and" operator, conversely, is &&.)

function GetTestCommandByExt()
  let l:extension = expand("%:e")
  if l:extension == "rb"
    return "rspec"
  elseif l:extension == "exs"
    return "mix test"

    " Adding the following two lines"
  elseif l:extension == "js" || l:extension == "ts" || l:extension == "tsx"
    return "npm test -- --watch-all=false"

  else 
    throw 'Test file extension not recognized.'
  endif
endfunction

With that, our TestAll() and TestFile() methods work great.

posts/2021-01-05-vim-script-case-study/jest-test-file.gif

There is, however, a tiny problem with our TestThis() method. TestThis()
is configured to run a single test based on the current line of our cursor. As
of this writing, Jest commands do not accept line numbers to run a single test.
Instead they can accept a pattern based on the name of the
test
. As a
result, when we hit <Leader>tt to run TestThis(), our vertical terminal
opens up and displays an unhelpful error.

posts/2021-01-05-vim-script-case-study/jest-test-this-problem.gif

There are arguably several ways we could handle this. For now, let's add a
special check in the TestThis() method. TestThis() will still look to
GetTestCommandByExt() for the relevant test command. But if the command
involves Jest, we'll throw an error. When caught by the catch block,
FireWarning() will display a custom message explaining the problem. Here's
how we can do this.

function TestThis()
  try
    let l:test_command = GetTestCommand()
    if IsJestTest(l:test_command)
      throw "Jest doesn't support testing a single test this way."
    endif
    execute "vert term" l:test_command "%:" . line(".")
  catch
    call FireWarning(v:exception)
  endtry
endfunction

function IsJestTest(test_command)
  let l:parsed_jest_command = matchstr(a:test_command, "--watch-all=false")
  return l:parsed_jest_command != ""
endfunction

What's going on here? To check if a Jest command is in play,
IsJestTest() uses a Vim built-in function, matchstr. [FN 16] This function checks
if its second argument is a substring of the first argument. If so, it returns
the substring. If not, it returns an empty string ("").

Here, matchstr checks if the --watchAll=false Jest flag is in our test
command. We save the results in the local variable l:parsed_jest_command. If
l:parsed_jest_command is an empty string, Jest is not in play. We can check
for this with Vim script's inequality comparison operator, != (as opposed to
Vim script's equality comparison operator, ==). [FN 17] Then IsJestTest() returns the result as a
boolean. Note that booleans in Vim script (as of this writing) are numbers.
0 is false. 1 is true (and any non-zero number is truthy as well).
[FN 18] If it turns out that we
have in fact solicited a Jest command when we only intend to run one test,
TestThis() throws an error with a helpful message that FireWarning()
displays.

posts/2021-01-05-vim-script-case-study/jest-warning.gif

We seemed to have solved the immediate problem with Jest. Nevertheless, our
conflict has exposed a larger problem with our entire Vim
script. There may come a time when we want to run other test commands that,
like Jest, do not play nicely with the command format exemplified by Rspec and
ExUnit. Or there may come a time when we want to add additional options or
flags to the test commands we do have access to. Fundamentally, we are limited
to whatever GetTestCommandByExt() understands and serves up. Is there a way
we can add more flexibility, without having to overhaul our script every time
one of these scenarios pops up?

As our last act, let's accomplish this with a global variable and just a bit
more logic. As the name implies, global variables are available anywhere in
our Vim session. They are also within the scope of every function. We can
declare a variable with let and prefix the variable name with g: to
designate it as global.

Our goal is to designate a global variable that we can set at will in
Command-line mode to override any other test command listed in
GetTestCommandByExt(). Let's plop a global variable in our Vim script.

let g:test_command_override = ""

From Command-line mode, we can set this global variable to whatever we like.
Say, for example, we wanted Rspec to display our test results with
"documentation" format, using the command rspec --format=documentation. [FN 19] We can set our global
variable to this command by typing the following in Command-line mode.

:let g:test_command_override = "rspec --format=documentation"

And how do we make sure our test runner uses this overriding command, rather
than a default command supplied by GetTestCommandByExt()? Let's define a
new function, GetTestCommand(). This function will ensure that if
g:test_command_override is set, the test runner will use it. The function
will also display a helpful message to remind the user what is going on. If
g:test_command_override has not been set, we'll look to
GetTestCommandByExt() for a default test command.

function GetTestCommand() 
  if g:test_command_override != ""
    echo 'Running test with g:test_command_override. Use :let g:test_command_override = "" to reset'
    return g:test_command_override
  else
    return GetTestCommandByExt()
  endif
endfunction

And we'll set each of TestAll(), TestFile(), and TestThis() to look to
GetTestCommmand(), rather than jumping first to GetTestCommandByExt(). This
is what TestAll() looks like after this update, for example:

function TestAll()
  try
    " Changing call from GetTestCommandByExt to GetTestCommand "
    let l:test_command = GetTestCommand()
    execute "vert term" l:test_command
  catch
    call FireWarning(v:exception)
  endtry
endfunction

posts/2021-01-05-vim-script-case-study/rspec-override.gif

Just for completeness, let's notify the user of the ability to set an
override if GetTestCommandByExt() does not recognize the file at hand.

function GetTestCommandByExt()
  " ... "
  " if file extension not recognized: "
    throw 'Test file extension not recognized. Use :let g:test_command_override="<command>" to set a custom test command.'
  " ... "
endfunction

posts/2021-01-05-vim-script-case-study/file-ext-warning-global.gif

With that, we now have more flexibility to run different types of tests. We
might even be able to use different testing frameworks altogether. Assuming the
test command we are substituting resembles the command patterns of Rspec, ExUnit,
or Jest, a substitution in Command-line mode as simple as this…

:let g:test_command_override = "<new base test command>"

…could open up completely new functionality to our simple keystrokes to
TestAll(), TestFile(), and TestThis().

The Whole Ball of Wax

This is what our entire Vim script looks like. It took us a while to understand
how to get here. Fortunately, if we decide to use the script below, it's not a
huge addition to our ~/.vimrc file. Even if we don't use it, hopefully the
journey to get here has fostered a burgeoning sense of the powerful, custom
configuration Vim script offers through keystroke mapping, functions,
conditional logic, echoing, color changes, etc. Having a sense of these tools,
and how to begin to use them, may make your life easier and offer incredible
value now or in the future.

nnoremap <Leader>ta :call TestAll()<cr>
nnoremap <Leader>tf :call TestFile()<cr>
nnoremap <Leader>tt :call TestThis()<cr>

function TestAll()
  try
    let l:test_command = GetTestCommand()
    execute "vert term" l:test_command
  catch
    call FireWarning(v:exception)
  endtry
endfunction

function TestFile()
  try
    let l:test_command = GetTestCommand()
    execute "vert term" l:test_command "%"
  catch
    call FireWarning(v:exception)
  endtry
endfunction

function TestThis()
  try
    let l:test_command = GetTestCommand()
    if IsJestTest(l:test_command)
      throw "Jest doesn't support testing a single test this way."
    endif
    execute "vert term" l:test_command "%:" . line(".")
  catch
    call FireWarning(v:exception)
  endtry
endfunction

function IsJestTest(test_command)
  let l:parsed_jest_command = matchstr(a:test_command, "--watch-all=false")
  return l:parsed_jest_command != ""
endfunction

let g:test_command_override = ""

function GetTestCommand() 
  if g:test_command_override != ""
    echo 'Running test with g:test_command_override. Use :let g:test_command_override = "" to reset'
    return g:test_command_override
  else
    return GetTestCommandByExt()
  endif
endfunction

function GetTestCommandByExt()
  let l:extension = expand("%:e")
  if l:extension == "rb"
    return "rspec"
  elseif l:extension == "js" || l:extension == "ts" || l:extension == "tsx"
    return "npm test -- --watch-all=false"
  elseif l:extension == "exs"
    return "mix test"
  else 
    throw 'Test file extension not recognized. Use :let g:test_command_override="<command>" to set a custom test command.'
  endif
endfunction

function FireWarning(warning)
  echohl WarningMsg
  echo a:warning
  echohl None
endfunction

nnoremap <Leader>cl :call CloseTest()<CR>

function CloseTest() 
  if &buftype == "terminal"
    let l:buffer_number = bufnr("%")
    let l:terminal_status = term_getstatus(buffer_number)
    if l:terminal_status == "finished"
      q
    endif
  endif
endfunction

posts/2021-01-05-vim-script-case-study/goal.gif


FOOTNOTES

1. For example, enter in Command-line mode `:help vimscript-versions`.
[Back]
2. A great explanation of the difference between `nmap` and `nnoremap` can be
found in

this

Stack Overflow answer. Note also that we can also create maps for other modes,
like Insert or Visual modes, or for every mode. See :help map-overview and
:help map-modes.
[Back]
3. :help mapleader
[Back]
4. :help range
[Back]
5. :help execute
[Back]
6. :help expr-..
[Back]
7. :help variable-scope
[Back]
8. :help expr-option
[Back]
9. :help windows-intro
[Back]
10. :help functions
[Back]
11. Vim’s built in helpers for detecting a file’s language include the
`FileType` event that can be used with autocommands. See, for example, `:help
FileType`, `:help filetypes`, and

here
.
[Back]
12. :help expand
[Back]
13. :help return
[Back]
14. :help echoerr
[Back]
15. Stack Overflow answers

here

and

here

tipped me off to the need for a custom error message function. It also took
me staring at `:help highlight` for an embarrassingly long time to understand
what was going on in these answers. It happens.
[Back]
16. I found this function, by chance, perusing the help file accessed through
`:help functions`. At the risk of repeating myself, it seems always
worth perusing this particular file.
[Back]
17. :help expression-syntax
[Back]
18. :help Boolean
[Back]
19. See, for example,

here
.
[Back]

Source: 8th Light