"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:
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)
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
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:
Afterward, of course, we hit the
Enter key. "Hello" flashes before us at the bottom of our Vim screen.
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
noremapbasically 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
,hto 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
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
nnoremap <Leader>h :echo "Hello!"<CR>
Many people set their
<Leader> key to a comma, which is why our "Hello!" command was mapped to
<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:
Running this in Command-line mode causes Vim to disappear. Our terminal command-line appears, and our tests are run.
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:
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.
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:
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:
Conversely, this will open a vertically split window (which I happen to like better, so we'll continue to use it below):
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
:bd. We can also type
exit at the command prompt. If a process is still running, we could enter
: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
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>
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!. 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
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
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.
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
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:
bufferin Vim is an in-memory text of a file for editing. A
windowis how a user views a buffer. [FN 9]
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
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]
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
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:
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.
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
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() can then display the custom message as a warning.
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>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() 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.
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.
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
Let's add a new branch of logic to
GetTestCommandByExt() to account for this. Our function will return
||, 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
TestFile() methods work great.
There is, however, a tiny problem with our
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.
There are arguably several ways we could handle this. For now, let's add a special check in the
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
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 (
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 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.
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
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
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
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
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
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