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

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

Tony Distinti
Tony Distinti

January 05, 2021

"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(l: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"
		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]